{"id":45915201,"url":"https://github.com/rameerez/organizations","last_synced_at":"2026-02-28T07:37:06.152Z","repository":{"id":339432901,"uuid":"1160542508","full_name":"rameerez/organizations","owner":"rameerez","description":"🏢 Add organizations to your Rails app, with team / user membership management and invites","archived":false,"fork":false,"pushed_at":"2026-02-19T20:21:18.000Z","size":529,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-02-19T20:25:19.556Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/rameerez.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null}},"created_at":"2026-02-18T04:18:47.000Z","updated_at":"2026-02-19T20:21:19.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/rameerez/organizations","commit_stats":null,"previous_names":["rameerez/organizations"],"tags_count":2,"template":false,"template_full_name":null,"purl":"pkg:github/rameerez/organizations","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rameerez%2Forganizations","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rameerez%2Forganizations/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rameerez%2Forganizations/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rameerez%2Forganizations/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rameerez","download_url":"https://codeload.github.com/rameerez/organizations/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rameerez%2Forganizations/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29924719,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-02-27T19:37:42.220Z","status":"online","status_checked_at":"2026-02-28T02:00:07.010Z","response_time":90,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-02-28T07:37:05.515Z","updated_at":"2026-02-28T07:37:06.143Z","avatar_url":"https://github.com/rameerez.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 🏢 `organizations` – Add organizations with members and invitations to your Rails SaaS\n\n[![Gem Version](https://badge.fury.io/rb/organizations.svg)](https://badge.fury.io/rb/organizations) [![Build Status](https://github.com/rameerez/organizations/workflows/Tests/badge.svg)](https://github.com/rameerez/organizations/actions)\n\n\u003e [!TIP]\n\u003e **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=organizations)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=organizations)!\n\n`organizations` adds organizations with members to any Rails app. It handles team invites, user memberships, roles, and permissions.\n\n**🎮 [Try the live demo →](https://organizations.rameerez.com)**\n\n[TODO: invitation / member management gif]\n\nIt's everything you need to turn a `User`-based app into a multi-tenant, `Organization`-based B2B SaaS (users belong in organizations, and organizations share resources and billing, etc.)\n\nIt's super easy:\n\n```ruby\nclass User \u003c ApplicationRecord\n  has_organizations\nend\n```\n\nThat's it. Your users can now create organizations, invite teammates, and jump between accounts:\n\n```ruby\ncurrent_user.create_organization!(\"Acme Corp\")\ncurrent_user.send_organization_invite_to!(\"teammate@acme.com\")\n```\n\nThen you could switch to the new org like this:\n\n```ruby\nswitch_to_organization!(@org)\n```\n\nAnd check your roles / permissions in relation to that organization like this:\n\n```ruby\ncurrent_user.is_organization_owner?     # =\u003e true\ncurrent_user.is_organization_admin?     # =\u003e true (owners inherit admin permissions)\n```\n\n## Installation\n\nAdd to your Gemfile:\n\n```ruby\ngem \"organizations\"\n```\n\n\u003e [!NOTE]\n\u003e For beautiful invitation emails, optionally add [`goodmail`](https://github.com/rameerez/goodmail).\n\nThen:\n\n```bash\nbundle install\nrails g organizations:install\nrails db:migrate\n```\n\nAdd `has_organizations` to your User model:\n\n```ruby\nclass User \u003c ApplicationRecord\n  has_organizations\nend\n```\n\nThat's the simplest setup. You can also configure per-model options:\n\n```ruby\nclass User \u003c ApplicationRecord\n  has_organizations do\n    max_organizations 5         # Limit how many orgs a user can own (nil = unlimited)\n    create_personal_org true    # Auto-create org on signup (default: false)\n    require_organization true   # Require users to have at least one org (default: false)\n  end\nend\n```\n\n\u003e **Note:** By default, users can exist without any organization (invite-to-join flow). Set `create_personal_org true` if you want to auto-create a personal organization when users sign up.\n\nMount the engine in your routes:\n\n```ruby\n# config/routes.rb\nmount Organizations::Engine =\u003e '/'\n```\n\nDone. Your app now has full organizations / teams support.\n\n\u003e [!IMPORTANT]\n\u003e **Bring Your Own UI (BYOU):** This gem provides all the building blocks — models, controllers, routes, helpers, and mailers — but intentionally **does not ship with views**. Views are too context-dependent (Tailwind vs Bootstrap, dark mode, your app's design system) to be one-size-fits-all. You'll need to create your own views in `app/views/organizations/`. For a complete working example, check out the demo app in [`test/dummy`](test/dummy/app/views/organizations/).\n\n\u003e [!NOTE]\n\u003e This gem uses the term \"organization\", but the concept is the same as \"team\", \"workspace\", or \"account\". It's essentially just an umbrella under which users / members are organized. This gem works for all those use cases, in the same way. Just use whichever term fits your product best in your UI.\n\n## Quick start\n\n### Create an organization\n\n```ruby\norg = current_user.create_organization!(\"Acme Corp\")\n# User automatically becomes the owner\n```\n\n### Invite teammates\n\n```ruby\ncurrent_user.send_organization_invite_to!(\"teammate@example.com\")\n# Sends invitation email: \"John invited you to join Acme Corp\"\n# When accepted, user joins as :member (default role)\n```\n\nThe invitation goes from user to user. The organization is inferred from `current_organization`. You can also be explicit:\n\n```ruby\ncurrent_user.send_organization_invite_to!(\"teammate@example.com\", organization: other_org)\n```\n\n### Check roles and permissions\n\n```ruby\n# Quick role checks (in current organization)\ncurrent_user.is_organization_owner?   # =\u003e true/false\ncurrent_user.is_organization_admin?   # =\u003e true/false\n\n# Permission checks\ncurrent_user.has_organization_permission_to?(:invite_members)\n# \"Does current user have organization permission to invite members?\"\n\n# Check role in a specific org\ncurrent_user.is_admin_of?(@org)\n# \"Is current user an admin of this org?\"\n\ncurrent_user.is_member_of?(@org)\n# \"Is current user a member of this org?\"\n```\n\n### Switch between organizations\n\n```ruby\n# User belongs to multiple organizations? No problem.\ncurrent_user.organizations        # =\u003e [acme, startup_co, personal]\nswitch_to_organization!(startup_co)  # Changes active org in session\n```\n\n### Protect controllers\n\n```ruby\nclass ProjectsController \u003c ApplicationController\n  before_action :require_organization!\n  before_action :require_organization_admin!, only: [:create, :destroy]\nend\n```\n\n## Limit seats per plan (with `pricing_plans`)\n\n\u003e **Note:** This is an integration pattern, not built-in functionality. You implement the limit checks in your callbacks.\n\nIf you're using [`pricing_plans`](https://github.com/rameerez/pricing_plans), you can limit how many members an organization can have based on their subscription using callbacks:\n\n```ruby\n# config/initializers/pricing_plans.rb\nplan :hobby do\n  limits :organization_members, to: 3\nend\n\nplan :growth do\n  limits :organization_members, to: 25\nend\n```\n\nThen hook into the `on_member_invited` callback to enforce limits. **This callback runs BEFORE the invitation is persisted**, so raising an error will block the invitation:\n\n```ruby\n# config/initializers/organizations.rb\nOrganizations.configure do |config|\n  config.on_member_invited do |ctx|\n    org = ctx.organization\n    limit = org.current_plan.limit_for(:organization_members)\n\n    if limit \u0026\u0026 org.member_count \u003e= limit\n      raise Organizations::InvitationError, \"Member limit reached. Please upgrade your plan.\"\n    end\n  end\nend\n```\n\nThe `on_member_invited` callback is special — it runs in **strict mode**, meaning:\n- It executes **before** the invitation is saved to the database\n- Raising any error will **veto** the invitation (it won't be created)\n- The error message is returned to the user\n\nThis pattern gives you full control over how and when limits are enforced.\n\n## Why this gem exists\n\nOrganizations / teams are tough to do alone. Wiring up accounts, roles, and invites by hand is a pain you only want to go through once. If you don't implement organizations / teams on day one, adding them later becomes a major refactor — the kind that touches every model, controller, and permission in your app. Even experienced Rails developers have built accounts / teams poorly multiple times before getting it right.\n\nNo more asking yourself \"should I just roll my own?\" No more stitching together `acts_as_tenant` + `rolify` + `devise_invitable` + `pundit` and writing 500 lines of glue code. No more paying $250/year for a boilerplate template just because it has organizations / teams built in. The `organizations` gem gives you everything in a single `bundle add`.\n\nEvery B2B Rails app eventually needs organizations / teams. Yet there's no standalone gem that allows you to just flip a switch and add organizations to your app.\n\n| What you need | What exists today |\n|---------------|-------------------|\n| Organization model | ✅ Easy, just scaffold it |\n| Membership (User ↔ Organization join table) | ❌ Write it yourself |\n| Invite users to a *specific* organization | ❌ `devise_invitable` invites to the *app*, not to an org |\n| Roles scoped to each organization | ❌ `rolify` stores roles globally, not per-org |\n| Let users jump between organizations | ❌ Write it yourself |\n| **All of the above, integrated** | ❌ **Pay $250+ for a boilerplate** |\n\nThe day will come when you need to associate your users in organizations — and it will be the refactor from your worst nightmares. Rails does not make your life easy when you want to work this way, with multiple tenants. The typical Rails developer stitches together `acts_as_tenant` + `rolify` + `devise_invitable` + `pundit` and writes 500-1,000 lines of glue code that feels brittle compared to the usual simplicity of Rails. That takes 1-2 months. Some developers have estimated 200+ hours of work. Or you pay $250+/year for a boilerplate template where organizations / teams is the headline feature.\n\nLaravel has Jetstream with a `--teams` flag. Django has `django-organizations`. Rails has had nothing — until now.\n\n| Framework | Organizations / Teams Solution | Cost |\n|-----------|-------------------------------|------|\n| Laravel | Jetstream `--teams` | Free |\n| Django | django-organizations | Free |\n| Rails | `organizations` gem | Free |\n\n`organizations` gives you the complete `User → Membership → Organization` pattern with scoped invitations, hierarchical roles, and the ability to switch between organizations – all in a single, well-tested gem that works with your existing Devise setup. What previously took 1.5 months now takes 3 days.\n\n\u003e ![NOTE]\n\u003e This gem handles organization membership and org-level permissions (who can invite members, who can manage billing). For per-record authorization (\"can user X edit document Y\"), use [Pundit](https://github.com/varvet/pundit) or [CanCanCan](https://github.com/CanCanCommunity/cancancan) alongside this gem.\n\n## The complete API\n\n### User methods\n\nWhen you add `has_organizations` to your User model, you get:\n\n```ruby\n# Associations\nuser.organizations                      # All organizations user belongs to\nuser.memberships                        # All memberships (with roles)\nuser.owned_organizations                # Organizations where user is owner\nuser.pending_organization_invitations   # Invitations waiting to be accepted\n\n# Current organization context\nuser.organization                       # Alias for current_organization (most common use)\nuser.current_organization               # Active org for this session\nuser.current_membership                 # Membership in active org\nuser.current_organization_role          # Role in current org =\u003e :admin\n\n# Quick boolean checks\nuser.belongs_to_any_organization?           # \"Does user belong to any org?\"\nuser.has_pending_organization_invitations?  # \"Does user have pending invites?\"\n\n# Permission checks (in current organization)\nuser.has_organization_permission_to?(:invite_members)   # =\u003e true/false\nuser.has_organization_role?(:admin)                     # =\u003e true/false\n\n# Role shortcuts (in current organization)\nuser.is_organization_owner?             # Same as has_organization_role?(:owner)\nuser.is_organization_admin?             # Same as has_organization_role?(:admin)\nuser.is_organization_member?            # Same as has_organization_role?(:member)\nuser.is_organization_viewer?            # Same as has_organization_role?(:viewer)\n\n# Role checks (explicit organization)\nuser.is_owner_of?(org)                  # \"Is user an owner of this org?\"\nuser.is_admin_of?(org)                  # \"Is user an admin of this org?\"\nuser.is_member_of?(org)                 # \"Is user a member of this org?\"\nuser.is_viewer_of?(org)                 # \"Is user a viewer of this org?\"\nuser.is_at_least?(:admin, in: org)      # \"Is user at least an admin in this org?\"\nuser.role_in(org)                       # =\u003e :admin\n\n# Actions\nuser.create_organization!(\"Acme\")            # Positional arg\nuser.create_organization!(name: \"Acme\")      # Keyword arg (both work)\nuser.leave_organization!(org)\nuser.leave_current_organization!             # Leave the active org\nuser.send_organization_invite_to!(email)                     # Invite to current org\nuser.send_organization_invite_to!(email, organization: org)  # Invite to specific org\n```\n\n### Organization methods\n\n```ruby\n# Associations\norg.memberships                 # All memberships\norg.members                     # All users (alias for org.users)\norg.users                       # All users (through memberships)\norg.invitations                 # All invitations (pending + accepted)\norg.pending_invitations         # Invitations not yet accepted\n\n# Queries\norg.owner                       # User who owns this org\norg.admins                      # Users with admin role or higher\norg.has_member?(user)           # \"Does org have this user as a member?\"\norg.has_any_members?            # \"Does org have any members?\"\norg.member_count                # Number of members\n\n# Class methods / Scopes\nOrganizations::Organization.with_member(user)  # Find all orgs where user is a member\n\n# Actions\norg.add_member!(user, role: :member)\norg.remove_member!(user)\norg.change_role_of!(user, to: :admin)\norg.transfer_ownership_to!(other_user)\n\n# Invitations (inviter must be a member with :invite_members permission)\norg.send_invite_to!(email)                  # Auto-infers invited_by from Current.user\norg.send_invite_to!(email, invited_by: user) # Explicit inviter\n\n# Scopes\norg.memberships.owners          # Memberships with owner role\norg.memberships.admins          # Memberships with admin role\norg.invitations.pending         # Not yet accepted\norg.invitations.expired         # Past expiration date\n```\n\n### Membership methods\n\n```ruby\nmembership.role                 # =\u003e \"admin\"\nmembership.organization         # The organization\nmembership.user                 # The user\nmembership.invited_by           # User who invited them (if any)\n\n# Permission checks\nmembership.has_permission_to?(:invite_members)   # =\u003e true/false\nmembership.permissions                           # =\u003e [:view_members, :invite_members, ...]\n\n# Role hierarchy checks\nmembership.is_at_least?(:member)    # =\u003e true (if member, admin, or owner)\n\n# Role changes\nmembership.promote_to!(:admin)      # Change role to admin\nmembership.demote_to!(:member)      # Change role to member\n```\n\n### Invitation methods\n\n```ruby\ninvitation.email                # =\u003e \"teammate@example.com\"\ninvitation.organization         # The organization\ninvitation.role                 # Role they'll have when accepted\ninvitation.invited_by           # User who sent the invitation\ninvitation.from                 # Alias for invited_by\ninvitation.pending?             # =\u003e true (not yet accepted)\ninvitation.accepted?            # =\u003e true (has accepted_at)\ninvitation.expired?             # =\u003e true (past expires_at)\n\n# Actions\ninvitation.accept!              # Accept (auto-infers Current.user)\ninvitation.accept!(user)        # Accept with explicit user\ninvitation.resend!              # Send invitation email again\n```\n\n## Controller helpers\n\nInclude the controller concern in your ApplicationController:\n\n```ruby\nclass ApplicationController \u003c ActionController::Base\n  include Organizations::Controller\nend\n```\n\nThis gives you:\n\n```ruby\n# Context helpers\ncurrent_organization            # Active organization (from session)\ncurrent_membership              # Current user's membership in active org\norganization_signed_in?         # Is there an active organization?\n\n# Pending invitation helpers\npending_organization_invitation_token   # Get pending invitation token from session\npending_organization_invitation         # Get pending invitation (clears if expired)\npending_organization_invitation?        # Check if valid pending invitation exists\npending_organization_invitation_email   # Get invited email (for signup prefill)\nclear_pending_organization_invitation!  # Clear invitation token and cache\n\n# Invitation acceptance (canonical helper for post-signup flows)\naccept_pending_organization_invitation!(user)                    # Accept with session token\naccept_pending_organization_invitation!(user, token: token)      # Explicit token\naccept_pending_organization_invitation!(user, switch: false)     # Don't auto-switch org\naccept_pending_organization_invitation!(user, return_failure: true) # Returns failure object on rejection\npending_invitation_acceptance_redirect_path_for(user)            # Accept + resolve redirect path\nhandle_pending_invitation_acceptance_for(user, redirect: true)   # Accept + optionally redirect\n# Returns InvitationAcceptanceResult or nil\n\n# Invitation redirect helpers\nredirect_path_when_invitation_requires_authentication(invitation)  # Get auth redirect\nredirect_path_after_invitation_accepted(invitation, user: user)    # Get post-accept redirect\nredirect_path_after_organization_switched(org, user: user)         # Get post-switch redirect\n\n# No-organization helpers\nredirect_path_when_no_organization(user: nil)                     # Resolve configured no-org redirect path\nno_organization_redirect_path                                     # Alias\nredirect_to_no_organization!(alert: \"...\", notice: \"...\")         # Redirect and return false\n\n# Organization creation helper\ncreate_organization_and_switch!(current_user, name: \"Acme\")       # Create and set context in one call\ncreate_organization_with_context!(current_user, name: \"Acme\")     # Backward-compatible alias\n\n# Authorization\nrequire_organization!                               # Redirect if no active org\nrequire_organization_role!(:admin)                  # Require at least admin role\nrequire_organization_permission_to!(:invite_members) # Require specific permission\n\n# Authorization shortcuts (for common roles)\nrequire_organization_owner!     # Same as require_organization_role!(:owner)\nrequire_organization_admin!     # Same as require_organization_role!(:admin)\n\n# Switching\nswitch_to_organization!(org)              # Change active org in session\nswitch_to_organization!(org, user: user)  # Explicit user (for auth-transition flows)\n```\n\n### Protecting resources\n\n```ruby\nclass SettingsController \u003c ApplicationController\n  before_action :require_organization!\n  before_action :require_organization_admin!  # Shortcut for require_organization_role!(:admin)\n\n  def billing\n    require_organization_owner!  # Only owners can manage billing\n  end\nend\n```\n\n### Handling unauthorized access\n\nConfigure how unauthorized access is handled:\n\n```ruby\n# config/initializers/organizations.rb\nOrganizations.configure do |config|\n  config.on_unauthorized do |context|\n    # context.user, context.organization, context.permission, context.required_role\n    redirect_to root_path, alert: \"You don't have permission to do that.\"\n  end\n\n  config.on_no_organization do |context|\n    redirect_to new_organization_path, alert: \"Please create or join an organization first.\"\n  end\nend\n```\n\n## View helpers\n\nInclude in your ApplicationHelper:\n\n```ruby\nmodule ApplicationHelper\n  include Organizations::ViewHelpers\nend\n```\n\n### Permission checks in views\n\n```ruby\n\u003c% if current_user.has_organization_permission_to?(:invite_members) %\u003e\n  \u003c%= link_to \"Invite teammate\", new_invitation_path %\u003e\n\u003c% end %\u003e\n\n\u003c% if current_user.is_organization_admin? %\u003e\n  \u003c%= link_to \"Settings\", organization_settings_path %\u003e\n\u003c% end %\u003e\n```\n\n### Organization switcher\n\nBuild your own switcher UI with the helper:\n\n```ruby\n\u003c% data = organization_switcher_data %\u003e\n\n\u003cdiv class=\"org-switcher\"\u003e\n  \u003cbutton\u003e\u003c%= data[:current][:name] %\u003e\u003c/button\u003e\n  \u003cul\u003e\n    \u003c% data[:others].each do |org| %\u003e\n      \u003cli\u003e\n        \u003c%= link_to org[:name], data[:switch_path].call(org[:id]) %\u003e\n      \u003c/li\u003e\n    \u003c% end %\u003e\n  \u003c/ul\u003e\n\u003c/div\u003e\n```\n\nThe helper returns:\n\n```ruby\n{\n  current: { id: \"...\", name: \"Acme Corp\" },\n  others: [\n    { id: \"...\", name: \"Personal\" },\n    { id: \"...\", name: \"StartupCo\" }\n  ],\n  switch_path: -\u003e(org_id) { \"/organizations/switch/#{org_id}\" }\n}\n```\n\n### Invitation badge\n\n```ruby\n# Show pending invitation count in your navbar\n\u003c%= organization_invitation_badge(current_user) %\u003e\n# =\u003e \u003cspan class=\"badge\"\u003e3\u003c/span\u003e (if 3 pending invitations)\n# =\u003e nil (if no pending invitations)\n```\n\n## Roles and permissions\n\n### Organization permissions vs. resource authorization\n\n`organizations` handles **org-level permissions** — what a user can do *within an organization*:\n\n```ruby\ncurrent_user.has_organization_permission_to?(:invite_members)\n# \"Can they invite people to this org?\"\n\ncurrent_user.has_organization_permission_to?(:manage_billing)\n# \"Can they manage this org's billing?\"\n\nrequire_organization_permission_to!(:manage_settings)\n# Gate org settings pages\n```\n\nThis is different from **resource authorization** (Pundit, CanCanCan) — what a user can do *to a specific record*:\n\n```ruby\n# Pundit/CanCanCan territory (not what this gem does)\npolicy(@document).update?      # \"Can they edit THIS specific document?\"\nauthorize! :destroy, @project  # \"Can they delete THIS specific project?\"\n```\n\n| | `organizations` gem | Pundit / CanCanCan |\n|---|---------------------|-------------------|\n| **Question** | \"What can this user do in this org?\" | \"Can this user do X to record Y?\" |\n| **Scope** | Organization-wide capabilities | Per-record authorization |\n| **Based on** | Role in Membership | Policy classes / Ability rules |\n| **Example** | \"Admins can invite members\" | \"Users can edit their own posts\" |\n\n**Most B2B apps need both.** Use `organizations` for org membership and capabilities. Use Pundit/CanCanCan for fine-grained resource authorization. They're complementary, not competing.\n\n### Built-in roles\n\n`organizations` ships with four hierarchical roles:\n\n```\nowner \u003e admin \u003e member \u003e viewer\n```\n\nEach role inherits all permissions from roles below it.\n\n### Default permissions\n\n| Permission | viewer | member | admin | owner |\n|------------|--------|--------|-------|-------|\n| `view_organization` | ✅ | ✅ | ✅ | ✅ |\n| `view_members` | ✅ | ✅ | ✅ | ✅ |\n| `create_resources` | | ✅ | ✅ | ✅ |\n| `edit_own_resources` | | ✅ | ✅ | ✅ |\n| `delete_own_resources` | | ✅ | ✅ | ✅ |\n| `invite_members` | | | ✅ | ✅ |\n| `remove_members` | | | ✅ | ✅ |\n| `edit_member_roles` | | | ✅ | ✅ |\n| `manage_settings` | | | ✅ | ✅ |\n| `view_billing` | | | ✅ | ✅ |\n| `manage_billing` | | | | ✅ |\n| `transfer_ownership` | | | | ✅ |\n| `delete_organization` | | | | ✅ |\n\n### Customize roles and permissions\n\nDefine your own roles in the initializer:\n\n```ruby\n# config/initializers/organizations.rb\nOrganizations.configure do |config|\n  config.roles do\n    role :viewer do\n      can :view_organization\n      can :view_members\n    end\n\n    role :member, inherits: :viewer do\n      can :create_resources\n      can :edit_own_resources\n      can :delete_own_resources\n    end\n\n    role :admin, inherits: :member do\n      can :invite_members\n      can :remove_members\n      can :edit_member_roles\n      can :manage_settings\n    end\n\n    role :owner, inherits: :admin do\n      can :manage_billing\n      can :transfer_ownership\n      can :delete_organization\n    end\n  end\nend\n```\n\n### Add custom permissions\n\n```ruby\nrole :admin, inherits: :member do\n  can :invite_members\n  can :remove_members\n  can :manage_api_keys      # Your custom permission\n  can :export_data          # Your custom permission\nend\n```\n\nThen check them anywhere:\n\n```ruby\ncurrent_user.has_organization_permission_to?(:manage_api_keys)\nrequire_organization_permission_to!(:export_data)\n```\n\n## Invitations\n\n### Sending invitations\n\nInvitations are user-to-user. The inviter is always explicit, and the email reads *\"John invited you to join Acme Corp\"*.\n\n```ruby\n# Invite to your current organization (most common)\ncurrent_user.send_organization_invite_to!(\"teammate@example.com\")\n\n# Invite to a specific organization\ncurrent_user.send_organization_invite_to!(\"teammate@example.com\", organization: other_org)\n\n# All invitees join as :member by default. Admins can promote after joining.\n```\n\nThere's also an organization-centric API if you prefer:\n\n```ruby\norg.send_invite_to!(\"teammate@example.com\", invited_by: current_user)\n```\n\n\u003e **Note:** Both APIs enforce authorization. The inviter must be a member of the organization with the `:invite_members` permission. If not, `Organizations::NotAMember` or `Organizations::NotAuthorized` is raised.\n\n### Invitation flow\n\nThe gem handles **both existing users and new signups** with a single invitation link:\n\n**For existing users:**\n1. Invitation created → Email sent with unique link\n2. User clicks link → Sees invitation details (org name, inviter, role)\n3. User clicks \"Accept\" → Membership created, redirected to org\n\n**For new users:**\n1. Invitation created → Email sent with unique link\n2. User clicks link → Sees invitation details + \"Sign up to accept\" button\n3. User registers → Token stored in session, your app calls `invitation.accept!(user)` post-signup\n\nThe gem stores the invitation token in `session[:organizations_pending_invitation_token]` when an unauthenticated user tries to accept. Use the built-in helper to accept the invitation in your auth callbacks:\n\n```ruby\n# In your ApplicationController (works with Devise or any auth system)\ndef after_sign_in_path_for(resource)\n  if (path = pending_invitation_acceptance_redirect_path_for(resource))\n    return path\n  end\n  super\nend\n\ndef after_sign_up_path_for(resource)\n  if (path = pending_invitation_acceptance_redirect_path_for(resource))\n    return path\n  end\n  super\nend\n```\n\nThe `accept_pending_organization_invitation!` helper handles:\n- Token lookup from session\n- Invitation validation (expired, already accepted, email match)\n- Membership creation\n- Organization context switching\n- Session cleanup\n\nIt returns an `InvitationAcceptanceResult` object or `nil`:\n\n```ruby\nresult = accept_pending_organization_invitation!(user)\nresult.accepted?      # =\u003e true if freshly accepted\nresult.already_member? # =\u003e true if user was already a member\nresult.switched?      # =\u003e true if org context was switched\nresult.invitation     # =\u003e the invitation record\nresult.membership     # =\u003e the membership record\n\n# Default flash notice (when using pending_invitation_acceptance_redirect_path_for)\n# accepted?       -\u003e \"Welcome to \u003corganization\u003e!\"\n# already_member? -\u003e \"You're already a member of \u003corganization\u003e.\"\n```\n\nIf you want structured failure reasons instead of `nil`, pass `return_failure: true`:\n\n```ruby\nresult = accept_pending_organization_invitation!(user, return_failure: true)\n\nif result.success?\n  # InvitationAcceptanceResult\nelse\n  # InvitationAcceptanceFailure\n  result.failure_reason # =\u003e :missing_token, :email_mismatch, :invitation_expired, etc.\nend\n```\n\nConfigure redirects in your initializer:\n\n```ruby\nOrganizations.configure do |config|\n  config.redirect_path_when_invitation_requires_authentication = \"/users/sign_up\"\n  config.redirect_path_after_invitation_accepted = \"/dashboard\"\n  config.redirect_path_after_organization_switched = \"/dashboard\"\n\n  # Or use procs for dynamic paths:\n  config.redirect_path_after_invitation_accepted = -\u003e(inv, user) {\n    \"/org/#{inv.organization_id}/welcome\"\n  }\n  config.redirect_path_after_organization_switched = -\u003e(org, user) {\n    \"/orgs/#{org.id}?user=#{user.id}\"\n  }\nend\n```\n\n\u003e **Note:** When accepting invitations in custom auth flows (Devise overrides, `bypass_sign_in`, etc.), the gem handles stale memoization issues automatically by passing the explicit user to `switch_to_organization!`.\n\n### Invitation emails\n\nThe gem ships with a clean ActionMailer-based invitation email.\n\n```ruby\n# Customize the mailer in config\nOrganizations.configure do |config|\n  config.invitation_mailer = \"Organizations::InvitationMailer\"  # Default\n  # Or use your own: config.invitation_mailer = \"CustomInvitationMailer\"\nend\n```\n\n### Invitation expiration\n\nInvitations expire after 7 days by default:\n\n```ruby\nOrganizations.configure do |config|\n  config.invitation_expiry = 7.days  # Default\n  # config.invitation_expiry = 30.days\n  # config.invitation_expiry = nil  # Never expire\nend\n```\n\nExpired invitations can be resent:\n\n```ruby\ninvitation.expired?   # =\u003e true\ninvitation.resend!    # Generates new token, resets expiry, sends email\n```\n\n### Accepted invitations\n\nAccepted invitations are kept for audit purposes:\n\n```ruby\norg.invitations.accepted  # Who was invited and when\ninvitation.accepted_at    # When they joined\ninvitation.invited_by     # Who sent the invitation\n```\n\n## Organization switching\n\nUsers can belong to multiple organizations. The \"current\" organization is stored in session, and all your queries scope to it automatically.\n\n### How it works\n\n1. User logs in → `current_organization` set to their most recently used org\n2. User switches org → Session updated, `current_organization` changes\n3. User is removed from current org → Auto-switches to next available org\n\n### Manual switching\n\n```ruby\n# In a controller\ndef switch\n  org = current_user.organizations.find(params[:id])\n  switch_to_organization!(org)\n  redirect_to dashboard_path\nend\n```\n\n### Routes provided by the engine\n\nWhen you mount the engine, you get:\n\n```\nPOST /organizations/switch/:id  → Organizations::SwitchController#create\nGET  /invitations/:token        → Organizations::PublicInvitationsController#show\nPOST /invitations/:token/accept → Organizations::PublicInvitationsController#accept\n```\n\n## Auto-created organizations\n\nBy default, users do **not** get an auto-created organization on signup (invite-to-join flow). You can enable this if you want:\n\n```ruby\n# When always_create_personal_organization_for_each_user is enabled:\n# 1. Organization created with name from config\n# 2. User becomes owner of that organization\n# 3. current_organization set to this new org\n```\n\n### Enable auto-creation\n\n```ruby\nOrganizations.configure do |config|\n  # Enable auto-creation (disabled by default)\n  config.always_create_personal_organization_for_each_user = true\n\n  # Customize the name\n  config.default_organization_name = -\u003e(user) { \"#{user.email.split('@').first}'s Workspace\" }\n  # Default: \"Personal\"\nend\n```\n\nBy default (`always_create_personal_organization_for_each_user = false`), users must explicitly create or be invited to an organization.\n\n### Users without organizations (default behavior)\n\nBy default, users can exist without any organization (invite-to-join flow):\n\n1. User signs up → verifies email\n2. User is in \"limbo\" (no organization yet)\n3. User creates org OR accepts invitation\n4. User now has an organization\n\nThis is the default behavior. If you want to auto-create a personal organization on signup, configure your User model:\n\n```ruby\nclass User \u003c ApplicationRecord\n  has_organizations do\n    create_personal_org true     # Auto-create org on signup\n    require_organization true    # Require users to always have an org\n  end\nend\n```\n\nWhen a user has no organization:\n\n```ruby\ncurrent_user.organization                    # =\u003e nil\ncurrent_user.current_organization            # =\u003e nil\ncurrent_user.belongs_to_any_organization?    # =\u003e false\ncurrent_user.is_organization_admin?          # =\u003e false (no org context)\n\n# In controllers\ncurrent_organization                         # =\u003e nil\norganization_signed_in?                      # =\u003e false\nrequire_organization!                        # Redirects to on_no_organization handler\n```\n\nHandle the limbo state in your views:\n\n```erb\n\u003c% if current_user.belongs_to_any_organization? %\u003e\n  \u003c%= render \"dashboard\" %\u003e\n\u003c% else %\u003e\n  \u003c%= render \"onboarding/create_or_join_organization\" %\u003e\n\u003c% end %\u003e\n```\n\nConfigure where to redirect users without an organization:\n\n```ruby\nOrganizations.configure do |config|\n  config.on_no_organization do |context|\n    redirect_to new_organization_path, notice: \"Create or join an organization to continue.\"\n  end\nend\n```\n\n## Configuration\n\nFull configuration options:\n\n```ruby\n# config/initializers/organizations.rb\nOrganizations.configure do |config|\n  # === Authentication ===\n  # Method that returns the current user (default: :current_user)\n  config.current_user_method = :current_user\n\n  # Method that ensures user is authenticated (default: :authenticate_user!)\n  config.authenticate_user_method = :authenticate_user!\n\n  # === Auto-creation ===\n  # Create personal organization on user signup (default: false)\n  config.always_create_personal_organization_for_each_user = false\n\n  # Name for auto-created organizations\n  config.default_organization_name = -\u003e(user) { \"Personal\" }\n\n  # === Invitations ===\n  # How long invitations are valid\n  config.invitation_expiry = 7.days\n\n  # Custom mailer for invitations\n  config.invitation_mailer = \"Organizations::InvitationMailer\"\n\n  # === Limits ===\n  # Maximum organizations a user can own (nil = unlimited)\n  config.max_organizations_per_user = nil\n\n  # === Onboarding ===\n  # Require users to belong to at least one organization\n  # Set to true if users should always have an organization\n  config.always_require_users_to_belong_to_one_organization = false  # Default\n\n  # === Redirects ===\n  # Where to redirect when user has no organization\n  config.redirect_path_when_no_organization = \"/organizations/new\"\n\n  # Where to redirect after organization is created (nil = default show page)\n  # Can be a String or Proc: -\u003e(org) { \"/orgs/#{org.id}/setup\" }\n  config.after_organization_created_redirect_path = \"/dashboard\"\n\n  # === Invitation Flow Redirects ===\n  # Where to redirect unauthenticated users when they try to accept an invitation\n  # Default: nil (uses new_user_registration_path or root_path)\n  config.redirect_path_when_invitation_requires_authentication = \"/users/sign_up\"\n  # Or use a Proc: -\u003e(invitation, user) { \"/signup?invite=#{invitation.token}\" }\n\n  # Where to redirect after an invitation is accepted\n  # Default: nil (uses root_path)\n  config.redirect_path_after_invitation_accepted = \"/dashboard\"\n  # Or use a Proc: -\u003e(invitation, user) { \"/org/#{invitation.organization_id}/welcome\" }\n\n  # Where to redirect after organization switch\n  # Default: nil (uses root_path)\n  config.redirect_path_after_organization_switched = \"/dashboard\"\n  # Or use a Proc: -\u003e(organization, user) { \"/orgs/#{organization.id}\" }\n\n  # Optional flash messages for built-in no-organization redirects.\n  # Leave nil to keep default alert behavior.\n  config.no_organization_alert = \"Please create an organization first.\"\n  config.no_organization_notice = \"Please create or join an organization to continue.\"\n\n  # === Organizations Controller ===\n  # Additional params to permit when creating/updating organizations\n  # Use this to add custom fields like support_email, billing_email, logo\n  config.additional_organization_params = [:support_email]\n\n  # === Engine Controllers ===\n  # Base controller for authenticated routes (default: ::ApplicationController)\n  config.parent_controller = \"::ApplicationController\"\n\n  # Base controller for public routes like invitation acceptance.\n  # Works with Devise out of the box - no configuration needed.\n  # Only override if using custom auth or needing specific inheritance.\n  # Default: ActionController::Base\n  # config.public_controller = \"ActionController::Base\"\n\n  # Layout overrides for engine controllers (optional)\n  # Resolved at request-time, so runtime config changes are respected.\n  config.authenticated_controller_layout = \"dashboard\"\n  config.public_controller_layout = \"devise\"\n\n  # === Handlers ===\n  # Called when authorization fails\n  config.on_unauthorized do |context|\n    redirect_to root_path, alert: \"Not authorized\"\n  end\n\n  # Called when no organization is set\n  config.on_no_organization do |context|\n    redirect_to config.redirect_path_when_no_organization\n  end\n\n  # === Roles \u0026 Permissions ===\n  config.roles do\n    # ... (see Roles and Permissions section)\n  end\n\n  # === Callbacks ===\n  config.on_organization_created do |ctx|\n    # ctx.organization, ctx.user (owner)\n  end\n\n  config.on_member_invited do |ctx|\n    # ctx.organization, ctx.invitation, ctx.invited_by\n  end\n\n  config.on_member_joined do |ctx|\n    # ctx.organization, ctx.membership, ctx.user\n  end\n\n  config.on_member_removed do |ctx|\n    # ctx.organization, ctx.membership, ctx.user, ctx.removed_by\n  end\n\n  config.on_role_changed do |ctx|\n    # ctx.organization, ctx.membership, ctx.old_role, ctx.new_role, ctx.changed_by\n  end\n\n  config.on_ownership_transferred do |ctx|\n    # ctx.organization, ctx.old_owner, ctx.new_owner\n  end\nend\n```\n\n## Integrations\n\n### Works with Devise out of the box\n\n`organizations` is built for Devise. It uses `current_user` and `authenticate_user!` by default. Just add `has_organizations` and you're done.\n\n### Works with other auth systems\n\nUsing Rodauth, Sorcery, or custom auth? Configure the methods:\n\n```ruby\nOrganizations.configure do |config|\n  config.current_user_method = :current_account\n  config.authenticate_user_method = :require_login\nend\n```\n\n### Integrates with acts_as_tenant\n\nFor automatic query scoping, include the integration concern:\n\n```ruby\nclass ApplicationController \u003c ActionController::Base\n  include Organizations::Controller\n  include Organizations::ActsAsTenantIntegration\n  # Automatically calls: set_current_tenant(current_organization)\nend\n```\n\n### Integrates with pricing_plans\n\nEnforce member limits based on pricing plans using callbacks:\n\n```ruby\n# In your Organization model\nclass Organization \u003c ApplicationRecord\n  include PricingPlans::PlanOwner\nend\n\n# In config/initializers/pricing_plans.rb\nplan :starter do\n  limits :members, to: 5\nend\n\nplan :pro do\n  limits :members, to: 50\nend\n```\n\nThen hook into callbacks to enforce limits (see \"Limit seats per plan\" section above for full example).\n\n### Integrates with your gem ecosystem\n\n`organizations` is designed to work with rameerez's gem ecosystem:\n\n```ruby\n# Organization owns API keys (api_keys gem)\nclass Organization \u003c ApplicationRecord\n  has_api_keys do\n    max_keys 10\n  end\nend\n\n# Organization has credits (usage_credits gem)\nclass Organization \u003c ApplicationRecord\n  has_credits\nend\n\n# Organization has pricing plan (pricing_plans gem)\nclass Organization \u003c ApplicationRecord\n  include PricingPlans::PlanOwner\nend\n```\n\nAll scoped through `current_organization`:\n\n```ruby\ncurrent_organization.api_keys\ncurrent_organization.credits\ncurrent_organization.current_pricing_plan\ncurrent_organization.memberships  # From organizations gem\n```\n\n## Callbacks\n\nHook into organization lifecycle events:\n\n```ruby\nOrganizations.configure do |config|\n  config.on_organization_created do |ctx|\n    SlackNotifier.notify(\"New org: #{ctx.organization.name}\")\n    Analytics.track(ctx.user, \"organization_created\")\n  end\n\n  config.on_member_joined do |ctx|\n    WelcomeMailer.send_team_welcome(ctx.user, ctx.organization).deliver_later\n    Analytics.track(ctx.user, \"joined_organization\", org: ctx.organization.name)\n  end\n\n  config.on_member_removed do |ctx|\n    AuditLog.record(\n      action: :member_removed,\n      organization: ctx.organization,\n      user: ctx.user,\n      actor: ctx.removed_by\n    )\n  end\nend\n```\n\n### Available callbacks\n\n| Callback | Context fields | Mode |\n|----------|----------------|------|\n| `on_organization_created` | `organization`, `user` | After |\n| `on_member_invited` | `organization`, `invitation`, `invited_by` | **Before (strict)** |\n| `on_member_joined` | `organization`, `membership`, `user` | After |\n| `on_member_removed` | `organization`, `membership`, `user`, `removed_by` | After |\n| `on_role_changed` | `organization`, `membership`, `old_role`, `new_role`, `changed_by` | After |\n| `on_ownership_transferred` | `organization`, `old_owner`, `new_owner` | After |\n\n**Callback modes:**\n- **After**: Runs after the action completes. Errors are logged but don't block the operation. Use for notifications, analytics, and audit logs.\n- **Before (strict)**: Runs before the action. Raising an error **vetoes** the operation. Use for validation and policy enforcement (e.g., seat limits).\n\n## Testing\n\n### Test helpers\n\n```ruby\n# test/test_helper.rb\nrequire \"organizations/test_helpers\"\n\nclass ActiveSupport::TestCase\n  include Organizations::TestHelpers\nend\n```\n\n### Fixtures\n\nThe gem works with Rails fixtures:\n\n```yaml\n# test/fixtures/organizations.yml\nacme:\n  name: Acme Corp\n\n# test/fixtures/memberships.yml\njohn_at_acme:\n  user: john\n  organization: acme\n  role: admin\n```\n\n### Test helpers\n\n```ruby\n# Set organization context in tests\nsign_in_as_organization_member(user, org, role: :admin)\nset_current_organization(org)\n\n# Or manually\nsign_in user\nswitch_to_organization!(org)\n```\n\n### Minitest assertions\n\n```ruby\nassert user.is_member_of?(org)\nassert user.is_owner_of?(org)\nassert user.is_organization_admin?\nassert user.has_organization_permission_to?(:invite_members)\nassert user.belongs_to_any_organization?\n```\n\n## Extending the Organization model\n\nThe gem provides `Organizations::Organization` as the base model. You can extend it with your app's specific fields by adding migrations and reopening the class:\n\n```ruby\n# db/migrate/xxx_add_custom_fields_to_organizations.rb\nclass AddCustomFieldsToOrganizations \u003c ActiveRecord::Migration[8.0]\n  def change\n    add_column :organizations_organizations, :support_email, :string\n    add_column :organizations_organizations, :billing_address, :text\n    add_column :organizations_organizations, :settings, :jsonb, default: {}\n  end\nend\n```\n\n```ruby\n# config/initializers/organization_extensions.rb\n# Or: app/models/concerns/organization_extensions.rb (then include in initializer)\n\nOrganizations::Organization.class_eval do\n  # Add your own associations\n  has_many :projects\n  has_many :documents\n\n  # Add your own validations\n  validates :support_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }\n\n  # Add your own methods\n  def active_projects\n    projects.where(archived: false)\n  end\nend\n```\n\nAlternatively, create your own model that inherits from the gem's model:\n\n```ruby\n# app/models/organization.rb\nclass Organization \u003c Organizations::Organization\n  has_many :projects\n  has_many :documents\n\n  validates :support_email, presence: true\nend\n```\n\n\u003e **Note:** If you create your own `Organization` class, be aware that internal gem code uses `Organizations::Organization`. Your subclass will work for your app code, but associations from `User#organizations` will return `Organizations::Organization` instances.\n\nThis is standard Rails practice — the gem provides the foundation (memberships, invitations, roles), your app extends it with domain-specific features.\n\n## Database schema\n\nThe gem creates three tables:\n\n### organizations_organizations\n\n```sql\norganizations_organizations\n  - id (primary key, auto-detects UUID or integer from your app)\n  - name (string, required)\n  - metadata (jsonb, default: {})\n  - created_at\n  - updated_at\n```\n\n\u003e **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.\n\n### organizations_memberships\n\n```sql\norganizations_memberships\n  - id (primary key)\n  - user_id (foreign key, indexed)\n  - organization_id (foreign key, indexed)\n  - role (string, default: 'member')\n  - invited_by_id (foreign key, nullable)\n  - created_at\n  - updated_at\n\n  unique index: [user_id, organization_id]\n```\n\n### organizations_invitations\n\n```sql\norganizations_invitations\n  - id (primary key)\n  - organization_id (foreign key, indexed)\n  - email (string, required, indexed)\n  - role (string, default: 'member')\n  - token (string, unique, indexed)\n  - invited_by_id (foreign key, nullable)\n  - accepted_at (datetime, nullable)\n  - expires_at (datetime)\n  - created_at\n  - updated_at\n\n  unique index: [organization_id, email] where accepted_at is null\n```\n\n## Ownership rules\n\n- Every organization has exactly one owner\n- Owner cannot leave the organization (must transfer ownership first)\n- Ownership can be transferred to any admin: `org.transfer_ownership_to!(other_admin)`\n- When ownership is transferred, old owner becomes admin\n\n## Edge cases handled\n\n| Scenario | Behavior |\n|----------|----------|\n| User removed from current org | Auto-switches to next available org |\n| User has no organizations | Redirects to configurable path (or allowed if `always_require_users_to_belong_to_one_organization: false`) |\n| User signs up, no org yet | `current_organization` returns `nil`, `belongs_to_any_organization?` returns `false` |\n| Last owner tries to leave | Raises `CannotLeaveAsLastOwner`, must transfer ownership first |\n| Two admins leave simultaneously | Row-level lock prevents both from leaving if one would be last |\n| Invitation accepted twice (race condition) | Row-level lock, second request returns existing membership |\n| Two admins invite same email | Unique constraint, second returns existing invitation |\n| Invitation for existing member | Returns error, doesn't duplicate |\n| Expired invitation resent | New token generated, expiry reset |\n| Ownership transfer to removed user | Transaction lock, verifies membership exists before transfer |\n| Concurrent role changes on same user | Row-level lock on membership row |\n| Session points to org user was removed from | `current_organization` verifies membership, clears stale session |\n| Token collision on invitation | Unique constraint, regenerates token |\n\n## Performance notes\n\nThe gem is designed to avoid N+1 queries when used correctly. Here's what you need to know.\n\n### Eager loading for listings\n\nWhen iterating over memberships or invitations, use `includes` to avoid N+1:\n\n```ruby\n# Listing members — GOOD\norg.memberships.includes(:user).each do |membership|\n  membership.user.name  # No N+1\nend\n\n# Listing members — BAD (N+1 on user)\norg.memberships.each do |membership|\n  membership.user.name  # Queries DB for each user\nend\n\n# Listing invitations — GOOD\norg.invitations.includes(:invited_by).each do |invitation|\n  invitation.invited_by.name  # No N+1\nend\n```\n\n### Permission checks are in-memory\n\nPermission checks **never hit the database**. They read the role from the already-loaded membership and check against a pre-computed permission hash:\n\n```ruby\n# This does NOT query the DB\nuser.has_organization_permission_to?(:invite_members)\n\n# Safe to call in loops\norg.memberships.includes(:user).each do |m|\n  m.has_permission_to?(:invite_members)  # No DB query, just hash lookup\nend\n```\n\n### Role checks with explicit org\n\nWhen checking roles against a specific organization, the gem is smart about reusing loaded data:\n\n```ruby\n# If memberships are already loaded, this won't query again\nuser.organizations.includes(:memberships).each do |org|\n  user.is_admin_of?(org)  # Reuses loaded membership\nend\n\n# But if you call it in isolation, it queries the DB\nuser.is_admin_of?(some_org)  # Single query to find membership\n```\n\n### Organization switcher optimization\n\nThe `organization_switcher_data` helper is optimized for navbar use:\n\n```ruby\n# Internally, it:\n# 1. Selects only id, name (not full objects)\n# 2. Memoizes within the request\n# 3. Returns a lightweight hash, not ActiveRecord objects\n\norganization_switcher_data\n# =\u003e { current: { id: \"...\", name: \"Acme\" }, others: [...] }\n```\n\n### Counter caches for member counts\n\nIf you display member counts frequently (pricing pages, org listings), consider adding a counter cache:\n\n```ruby\n# In a migration\nadd_column :organizations_organizations, :memberships_count, :integer, default: 0, null: false\n\n# Reset existing counts\nOrganization.find_each do |org|\n  Organization.reset_counters(org.id, :memberships)\nend\n```\n\nThe gem automatically uses the counter cache if present:\n\n```ruby\norg.member_count\n# Uses memberships_count column if it exists\n# Falls back to COUNT(*) query otherwise\n```\n\n### Existence checks use SQL\n\nBoolean checks use efficient SQL `EXISTS` queries:\n\n```ruby\nuser.belongs_to_any_organization?     # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1\nuser.has_pending_organization_invitations?  # SELECT 1 FROM organizations_invitations WHERE ... LIMIT 1\norg.has_any_members?                  # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1\n```\n\n### Scoped associations use JOINs\n\nMethods like `org.admins` and `user.owned_organizations` use proper SQL JOINs:\n\n```ruby\norg.admins\n# SELECT users.* FROM users\n# INNER JOIN organizations_memberships ON organizations_memberships.user_id = users.id\n# WHERE organizations_memberships.organization_id = ? AND organizations_memberships.role IN ('admin', 'owner')\n\nuser.owned_organizations\n# SELECT organizations_organizations.* FROM organizations_organizations\n# INNER JOIN organizations_memberships ON organizations_memberships.organization_id = organizations_organizations.id\n# WHERE organizations_memberships.user_id = ? AND organizations_memberships.role = 'owner'\n```\n\n### Current organization memoization\n\n`current_organization` is memoized within each request:\n\n```ruby\n# In your controller, these all return the same cached object\ncurrent_organization  # Queries DB (first call)\ncurrent_organization  # Returns cached (subsequent calls)\ncurrent_organization  # Returns cached\n```\n\n### Bulk operations\n\nFor bulk invitations (coming in roadmap), the gem will support skipping per-record callbacks:\n\n```ruby\n# Future API\norg.bulk_invite!(emails, skip_callbacks: true)\n# Fires on_bulk_invited once instead of on_member_invited N times\n```\n\n## Data integrity\n\nThe gem handles concurrent access and race conditions to ensure data consistency.\n\n### Unique constraints\n\nThese constraints prevent duplicate data at the database level:\n\n| Constraint | Purpose |\n|------------|---------|\n| `memberships [user_id, organization_id]` | User can only have one membership per org |\n| `invitations [organization_id, email] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |\n| `invitations [token]` | Invitation tokens are globally unique |\n\n### Row-level locking\n\nThe gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions:\n\n**Invitation acceptance:**\n```ruby\n# Two users clicking \"Accept\" on same invitation simultaneously\ninvitation.accept!\n\n# Internally:\n# 1. Lock invitation row\n# 2. Check accepted_at is nil\n# 3. Create membership\n# 4. Set accepted_at\n# 5. Release lock\n# Second request sees accepted_at is set, returns existing membership\n```\n\n**Ownership transfer:**\n```ruby\norg.transfer_ownership_to!(new_owner)\n\n# Internally:\n# 1. Lock organization row\n# 2. Lock old owner's membership\n# 3. Lock new owner's membership\n# 4. Verify new owner is a member\n# 5. Demote old owner to admin\n# 6. Promote new owner to owner\n# 7. Release locks\n```\n\n**Last admin/owner protection:**\n```ruby\nuser.leave_organization!(org)\n\n# Internally:\n# 1. Lock organization row\n# 2. Count remaining owners/admins\n# 3. If last owner, raise CannotLeaveAsLastOwner\n# 4. If allowed, destroy membership\n# 5. Release lock\n```\n\n**Role changes:**\n```ruby\nmembership.promote_to!(:admin)\n\n# Internally:\n# 1. Lock membership row\n# 2. Update role\n# 3. Release lock\n```\n\n### Transaction boundaries\n\nMulti-step operations are wrapped in transactions:\n\n```ruby\n# Organization creation (atomic)\nuser.create_organization!(\"Acme\")\n# Transaction: create org → create owner membership → set as current org\n\n# Invitation acceptance (atomic)\ninvitation.accept!\n# Transaction: lock invitation → create membership → update accepted_at\n\n# Ownership transfer (atomic)\norg.transfer_ownership_to!(user)\n# Transaction: lock rows → demote old owner → promote new owner\n```\n\n### Graceful handling of constraint violations\n\nWhen unique constraints are violated, the gem handles it gracefully:\n\n```ruby\n# Inviting an already-invited email\ncurrent_user.send_organization_invite_to!(\"already@invited.com\")\n# =\u003e Returns existing pending invitation (doesn't raise)\n\n# Accepting an already-accepted invitation\ninvitation.accept!\n# =\u003e Returns existing membership (doesn't raise)\n\n# Adding an existing member\norg.add_member!(existing_user)\n# =\u003e Returns existing membership (doesn't raise)\n```\n\n### Session integrity\n\nWhen a user is removed from their current organization:\n\n```ruby\n# User's session points to org_id = 123\n# Admin removes user from org 123\n\n# On user's next request:\ncurrent_organization\n# 1. Finds org 123\n# 2. Verifies user has membership in org 123\n# 3. Membership doesn't exist → clears session, returns nil\n# 4. require_organization! redirects to on_no_organization handler\n```\n\nThis prevents users from accessing organizations they've been removed from, even if their session still references that org.\n\n### Database indexes\n\nThe gem creates these indexes automatically:\n\n```sql\n-- Fast membership lookups\nCREATE UNIQUE INDEX index_organizations_memberships_on_user_and_org ON organizations_memberships (user_id, organization_id);\nCREATE INDEX index_organizations_memberships_on_organization_id ON organizations_memberships (organization_id);\nCREATE INDEX index_organizations_memberships_on_role ON organizations_memberships (role);\n\n-- Fast invitation lookups\nCREATE UNIQUE INDEX index_organizations_invitations_on_token ON organizations_invitations (token);\nCREATE INDEX index_organizations_invitations_on_email ON organizations_invitations (email);\nCREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;\n```\n\n## Migration from 1:1 relationships\n\nIf your app currently has `User belongs_to :organization` (1:1), migrate to `User has_many :organizations, through: :memberships` by backfilling memberships and removing direct `organization_id` dependencies incrementally.\n\n## Roadmap\n\n- [ ] Domain-based auto-join (users with @acme.com auto-join Acme org)\n- [ ] Bulk invitations (CSV upload)\n- [ ] Request-to-join workflow\n- [ ] Organization-level audit logs\n- [ ] Team hierarchies within organizations\n\n## Development\n\n```bash\ngit clone https://github.com/rameerez/organizations\ncd organizations\nbin/setup\nbundle exec rake test\n```\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/rameerez/organizations. Our code of conduct is: just be nice and make your mom proud of what you do and post online.\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frameerez%2Forganizations","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frameerez%2Forganizations","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frameerez%2Forganizations/lists"}