Class: Organizations::Organization
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- Organizations::Organization
- Extended by:
- MetadataFlags
- Defined in:
- lib/organizations/models/organization.rb
Overview
Organization model representing a team, workspace, or account. Users belong to organizations through memberships with specific roles.
Defined Under Namespace
Classes: CannotDemoteOwner, CannotHaveMultipleOwners, CannotInviteAsOwner, CannotRemoveOwner, CannotTransferToNonAdmin, CannotTransferToNonMember, MemberAlreadyExists, NoOwnerPresent
Class Method Summary collapse
-
.create_with_owner!(owner:, **attributes) ⇒ Organizations::Organization
Create an organization WITH its owner membership in one transaction — the ops/provisioning primitive.
Instance Method Summary collapse
-
#accepts_code_joining? ⇒ Boolean
Whether this organization has at least one code that can actually be redeemed right now (not revoked/expired/exhausted).
-
#accepts_domain_joining? ⇒ Boolean
Whether this organization accepts EMAIL-PROOF joining: an enrolled domain or an unclaimed roster entry (both run the same emailed-code challenge).
-
#accepts_join_requests? ⇒ Boolean
Whether this organization exposes any self-serve joining mechanism.
-
#add_domain!(domain, membership_metadata: {}) ⇒ Domain
Enroll an email domain for domain-verified joining.
-
#add_member!(user, role: :member) ⇒ Membership
Add a user as member with specified role Handles race conditions with unique constraint.
-
#admins ⇒ ActiveRecord::Relation<User>
Get all admins (users with admin role or higher) Uses efficient JOIN query to avoid N+1.
-
#approve_join_request!(join_request, approved_by: nil) ⇒ Membership
Approve a join request (creates the membership).
-
#change_role_of!(user, to:, changed_by: nil) ⇒ Membership
Change a user's role in the organization.
-
#generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true, expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {}) ⇒ JoinCode
Generate a shareable join code (PIN).
-
#has_any_members? ⇒ Boolean
Check if organization has any members Uses efficient EXISTS query.
-
#has_member?(user) ⇒ Boolean
Check if organization has a specific user as member.
-
#import_allowlist!(emails, source: nil, membership_metadata: {}) ⇒ Array<AllowlistEntry>
Bulk-import roster emails as allowlist entries (idempotent per address: already-enrolled addresses are skipped, not duplicated).
-
#join_with_account_email!(user) ⇒ Membership
Zero-friction domain join for hosts with confirmed account emails (e.g. Devise :confirmable): if the user's own account email is confirmed and its domain is enrolled, the inbox was already proven at signup — no emailed code needed.
-
#member_count ⇒ Integer
Get member count from the organizations counter cache.
-
#owner ⇒ User?
Get the owner of this organization.
-
#owner_membership ⇒ Membership?
Get the owner's membership.
-
#pending_invitations ⇒ ActiveRecord::Relation<Invitation>
Get pending invitations.
-
#pending_join_requests ⇒ ActiveRecord::Relation<JoinRequest>
Open join requests awaiting a decision.
-
#reject_join_request!(join_request, rejected_by: nil, reason: nil) ⇒ JoinRequest
Reject a join request.
-
#remove_member!(user, removed_by: nil) ⇒ Object
Remove a user from the organization.
-
#send_invite_to!(email, invited_by: nil, role: nil) ⇒ Invitation
Send an invitation to join this organization.
-
#transfer_ownership_to!(new_owner) ⇒ Object
Transfer ownership to another user The new owner must be an admin of the organization Old owner becomes admin.
-
#with_member ⇒ ActiveRecord::Relation
Find all organizations where a user is a member Uses efficient JOIN query.
Methods included from MetadataFlags
Class Method Details
.create_with_owner!(owner:, **attributes) ⇒ Organizations::Organization
Create an organization WITH its owner membership in one transaction —
the ops/provisioning primitive. This is what consoles, seed scripts,
and admin provisioning services should call instead of hand-rolling
create! + Membership.create!(role: "owner") (which one production
host did, accidentally skipping the organization_created callback).
Differences from user.create_organization! (the SELF-SERVE path):
- no session/current-organization switching (there is no session)
- not subject to max_organizations_per_user (an ops admin may own
hundreds of provisioned orgs)
- does NOT fire on_member_joining (owner-at-creation is not
"joining" — same contract as the rest of the gate)
- DOES fire on_organization_created, like every creation path.
⚠️ Because it skips the per-user cap, do NOT expose this from request-cycle self-serve code — users creating their own organizations go through user.create_organization!. This is for consoles, seeds, and admin-vetted provisioning services.
114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/organizations/models/organization.rb', line 114 def self.create_with_owner!(owner:, **attributes) raise ArgumentError, "owner is required" unless owner organization = nil ActiveRecord::Base.transaction do organization = create!(**attributes) Membership.create!(user: owner, organization: organization, role: "owner") end Callbacks.dispatch(:organization_created, organization: organization, user: owner) organization end |
Instance Method Details
#accepts_code_joining? ⇒ Boolean
Whether this organization has at least one code that can actually be redeemed right now (not revoked/expired/exhausted). Drives whether a join screen shows the code form.
559 560 561 |
# File 'lib/organizations/models/organization.rb', line 559 def accepts_code_joining? join_codes.active.exists? end |
#accepts_domain_joining? ⇒ Boolean
Whether this organization accepts EMAIL-PROOF joining: an enrolled domain or an unclaimed roster entry (both run the same emailed-code challenge). Drives whether a join screen shows the email form.
551 552 553 |
# File 'lib/organizations/models/organization.rb', line 551 def accepts_domain_joining? domains.exists? || allowlist_entries.unclaimed.exists? end |
#accepts_join_requests? ⇒ Boolean
Whether this organization exposes any self-serve joining mechanism. NOTE (0.5.0 refinement): codes now count only while actually redeemable — an org whose every code expired or ran out no longer reads as joinable.
568 569 570 |
# File 'lib/organizations/models/organization.rb', line 568 def accepts_join_requests? accepts_domain_joining? || accepts_code_joining? end |
#add_domain!(domain, membership_metadata: {}) ⇒ Domain
Enroll an email domain for domain-verified joining.
483 484 485 |
# File 'lib/organizations/models/organization.rb', line 483 def add_domain!(domain, membership_metadata: {}) domains.create!(domain: domain, membership_metadata: ) end |
#add_member!(user, role: :member) ⇒ Membership
Add a user as member with specified role Handles race conditions with unique constraint
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/organizations/models/organization.rb', line 193 def add_member!(user, role: :member) role_sym = role.to_sym validate_role!(role_sym) # Owner role is only assignable via transfer_ownership_to! or initial creation if role_sym == :owner raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_add_as_owner") end # Check if already a member (idempotent operation) existing = memberships.find_by(user_id: user.id) return existing if existing membership = nil created = false # requires_new: the rescued unique-violation below must roll back only # a SAVEPOINT — if a HOST wraps add_member! in its own transaction, a # plain nested block joins it, and on PostgreSQL the violation would # poison the host's whole transaction before the rescue could run the # idempotent lookup (see JoinRequest#create_membership!). ActiveRecord::Base.transaction(requires_new: true) do # Re-check under the transaction (review finding): a concurrent # add_member! that already inserted makes this call an idempotent # no-op — resolving here (instead of falling through to the gate) # keeps the gate from firing/vetoing a join that already happened, # and member_joined is NOT re-dispatched for it (the concurrent # creator already fired it). The RecordNotUnique rescue below stays # as the backstop for the window this narrows but cannot close. existing = memberships.find_by(user_id: user.id) next (membership = existing) if existing # THE MEMBERSHIP GATE (strict, vetoing, pre-persist): the one place a # host can abort ANY membership creation — seat limits, member caps. # Raising here rolls back cleanly (nothing persisted yet). Runs after # the idempotency check on purpose: an existing member isn't joining. Callbacks.dispatch( :member_joining, strict: true, organization: self, user: user, role: role_sym.to_s, joined_via: "manual" ) membership = memberships.create!( user: user, role: role_sym.to_s ) created = true end if created Callbacks.dispatch( :member_joined, organization: self, membership: membership, user: user ) end membership rescue ActiveRecord::RecordNotUnique # Race condition: membership was created by another process memberships.find_by!(user_id: user.id) end |
#admins ⇒ ActiveRecord::Relation<User>
Get all admins (users with admin role or higher) Uses efficient JOIN query to avoid N+1
149 150 151 |
# File 'lib/organizations/models/organization.rb', line 149 def admins users.where(organizations_memberships: { role: %w[owner admin] }).distinct end |
#approve_join_request!(join_request, approved_by: nil) ⇒ Membership
Approve a join request (creates the membership). See JoinRequest#approve!.
576 577 578 579 |
# File 'lib/organizations/models/organization.rb', line 576 def approve_join_request!(join_request, approved_by: nil) ensure_join_request_belongs_here!(join_request) join_request.approve!(decided_by: approved_by) end |
#change_role_of!(user, to:, changed_by: nil) ⇒ Membership
Change a user's role in the organization
293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 |
# File 'lib/organizations/models/organization.rb', line 293 def change_role_of!(user, to:, changed_by: nil) new_role = to.to_sym validate_role!(new_role) membership = memberships.find_by!(user_id: user.id) old_role = membership.role.to_sym return membership if old_role == new_role ActiveRecord::Base.transaction do # Lock organization to prevent race conditions lock! # Lock membership row to prevent concurrent changes membership.lock! # Enforce exactly-one-owner invariant if new_role == :owner && old_role != :owner # Promoting to owner - this is only allowed via transfer_ownership_to! # Direct role change to owner is not permitted raise CannotHaveMultipleOwners, Organizations.t(:"errors.cannot_promote_to_owner") end if old_role == :owner && new_role != :owner # Demoting owner - not allowed directly raise CannotDemoteOwner, Organizations.t(:"errors.cannot_demote_owner_directly") end membership.update!(role: new_role.to_s) end Callbacks.dispatch( :role_changed, organization: self, membership: membership, old_role: old_role, new_role: new_role, changed_by: changed_by ) membership end |
#generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true, expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {}) ⇒ JoinCode
Generate a shareable join code (PIN). Every parameter is an optional keyword with a safe default — this IS the public API surface, not incidental complexity. rubocop:disable Metrics/ParameterLists
499 500 501 502 503 504 505 506 507 508 509 510 |
# File 'lib/organizations/models/organization.rb', line 499 def generate_join_code!(label: nil, requires_verified_domain_email: false, auto_approve: true, expires_at: nil, max_uses: nil, created_by: nil, membership_metadata: {}) join_codes.create!( label: label, requires_verified_domain_email: requires_verified_domain_email, auto_approve: auto_approve, expires_at: expires_at, max_uses: max_uses, created_by_id: created_by&.id, membership_metadata: ) end |
#has_any_members? ⇒ Boolean
Check if organization has any members Uses efficient EXISTS query
168 169 170 |
# File 'lib/organizations/models/organization.rb', line 168 def has_any_members? memberships.exists? end |
#has_member?(user) ⇒ Boolean
Check if organization has a specific user as member
159 160 161 162 163 |
# File 'lib/organizations/models/organization.rb', line 159 def has_member?(user) return false unless user memberships.exists?(user_id: user.id) end |
#import_allowlist!(emails, source: nil, membership_metadata: {}) ⇒ Array<AllowlistEntry>
Bulk-import roster emails as allowlist entries (idempotent per address: already-enrolled addresses are skipped, not duplicated).
519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 |
# File 'lib/organizations/models/organization.rb', line 519 def import_allowlist!(emails, source: nil, membership_metadata: {}) Array(emails).filter_map do |email| # SAVEPOINT per entry: the duplicate-skip rescue below must survive # under a CALLER's transaction (real hosts wrap provisioning in one) # — on PostgreSQL the first duplicate would otherwise poison the # whole wrapping transaction and abort the import mid-list. entry = ActiveRecord::Base.transaction(requires_new: true) do allowlist_entries.create!( email: email, source: source, membership_metadata: ) end entry rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e # Skip duplicates silently (idempotent import); re-raise anything else. raise e unless duplicate_allowlist_error?(e) nil end end |
#join_with_account_email!(user) ⇒ Membership
Zero-friction domain join for hosts with confirmed account emails (e.g. Devise :confirmable): if the user's own account email is confirmed and its domain is enrolled, the inbox was already proven at signup — no emailed code needed.
600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 |
# File 'lib/organizations/models/organization.rb', line 600 def join_with_account_email!(user) unless Organizations.configuration.trust_confirmed_account_email raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_trust_disabled") end email = user.respond_to?(:email) ? user.email.to_s : "" confirmed = user.respond_to?(:confirmed_at) && user.confirmed_at.present? unless confirmed raise VerificationEmailNotEligible, Organizations.t(:"errors.account_email_unconfirmed") end matched_domain = domains.matching_email(email).first unless matched_domain raise VerificationEmailNotEligible, Organizations.t(:"errors.verification_email_not_eligible") end # Uniform funnel: every self-serve join goes through a JoinRequest so # provenance and audit trail come for free. request = join_requests.pending.find_by(user_id: user.id) || join_requests.new(user: user) normalized = Organizations.configuration.normalize_verification_email(email) ActiveRecord::Base.transaction do if Membership.where(organization_id: id, verified_email_normalized: normalized).exists? raise VerificationEmailAlreadyClaimed, Organizations.t(:"errors.verification_email_already_claimed") end request.assign_attributes( joined_via: "domain_email", verification_email: email, verification_email_normalized: normalized, verified_at: Time.current, metadata: (request. || {}).merge("matched_domain_id" => matched_domain.id) ) request.save! end request.approve!(decided_by: nil) end |
#member_count ⇒ Integer
Get member count from the organizations counter cache.
174 175 176 |
# File 'lib/organizations/models/organization.rb', line 174 def member_count memberships_count || 0 end |
#owner ⇒ User?
Get the owner of this organization
132 133 134 |
# File 'lib/organizations/models/organization.rb', line 132 def owner owner_membership&.user end |
#owner_membership ⇒ Membership?
Get the owner's membership
138 139 140 141 142 143 144 |
# File 'lib/organizations/models/organization.rb', line 138 def owner_membership if association(:memberships).loaded? memberships.find { |membership| membership.role == "owner" } else memberships.find_by(role: "owner") end end |
#pending_invitations ⇒ ActiveRecord::Relation<Invitation>
Get pending invitations
180 181 182 |
# File 'lib/organizations/models/organization.rb', line 180 def pending_invitations invitations.pending end |
#pending_join_requests ⇒ ActiveRecord::Relation<JoinRequest>
Open join requests awaiting a decision
543 544 545 |
# File 'lib/organizations/models/organization.rb', line 543 def pending_join_requests join_requests.pending end |
#reject_join_request!(join_request, rejected_by: nil, reason: nil) ⇒ JoinRequest
Reject a join request. See JoinRequest#reject!.
586 587 588 589 |
# File 'lib/organizations/models/organization.rb', line 586 def reject_join_request!(join_request, rejected_by: nil, reason: nil) ensure_join_request_belongs_here!(join_request) join_request.reject!(rejected_by: rejected_by, reason: reason) end |
#remove_member!(user, removed_by: nil) ⇒ Object
Remove a user from the organization
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/organizations/models/organization.rb', line 263 def remove_member!(user, removed_by: nil) membership = memberships.find_by(user_id: user.id) return unless membership if membership.role.to_sym == :owner raise CannotRemoveOwner, Organizations.t(:"errors.cannot_remove_owner") end ActiveRecord::Base.transaction do # Lock organization to prevent race conditions lock! membership.destroy! end Callbacks.dispatch( :member_removed, organization: self, membership: membership, user: user, removed_by: removed_by ) end |
#send_invite_to!(email, invited_by: nil, role: nil) ⇒ Invitation
Send an invitation to join this organization
397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 |
# File 'lib/organizations/models/organization.rb', line 397 def send_invite_to!(email, invited_by: nil, role: nil) inviter = invited_by || current_user_from_context unless inviter raise ArgumentError, "invited_by is required (or set Current.user)" end (inviter) role ||= Organizations.configuration.default_invitation_role role_sym = role.to_sym # Owner role cannot be assigned via invitation - only via transfer_ownership_to! if role_sym == :owner raise CannotInviteAsOwner, Organizations.t(:"errors.invite_as_owner") end normalized_email = email.downcase.strip # Check for existing pending invitation (idempotent) existing = invitations.pending.for_email(normalized_email).first return existing if existing # Check if already a member (case-insensitive) if users.where("LOWER(email) = ?", normalized_email).exists? raise Organizations::InvitationError, Organizations.t(:"errors.invitation_already_member") end # Allow callback hooks to veto invitations (e.g., plan seat limits) before write. invitation_context = invitations.build( email: normalized_email, invited_by: inviter, role: role.to_s, expires_at: calculate_expiry ) Callbacks.dispatch( :member_invited, strict: true, organization: self, invitation: invitation_context, invited_by: inviter ) invitation = nil # requires_new: the rescued unique-violation must roll back only a # SAVEPOINT so the method-level rescue's lookup still works when a HOST # wraps this call in its own transaction (PostgreSQL poisons the whole # transaction otherwise — see JoinRequest#create_membership!). ActiveRecord::Base.transaction(requires_new: true) do # Check for expired invitation and refresh it instead of creating duplicate expired_invitation = invitations.expired.for_email(normalized_email).first if expired_invitation expired_invitation.lock! expired_invitation.update!( invited_by: inviter, role: role.to_s, token: generate_unique_token, expires_at: calculate_expiry ) invitation = expired_invitation else invitation = invitations.create!( email: normalized_email, invited_by: inviter, role: role.to_s, token: generate_unique_token, expires_at: calculate_expiry ) end end # Send invitation email send_invitation_email(invitation) invitation rescue ActiveRecord::RecordNotUnique # Race condition: invitation was created by another process invitations.pending.for_email(normalized_email).first! end |
#transfer_ownership_to!(new_owner) ⇒ Object
Transfer ownership to another user The new owner must be an admin of the organization Old owner becomes admin
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/organizations/models/organization.rb', line 342 def transfer_ownership_to!(new_owner) ActiveRecord::Base.transaction do # Lock organization first to prevent concurrent operations lock! # Always perform a fresh read in this write path, even if memberships # are preloaded on this instance, to avoid stale-owner selection. old_owner_membership = memberships.find_by(role: "owner") new_owner_membership = memberships.find_by(user_id: new_owner.id) unless old_owner_membership raise NoOwnerPresent, Organizations.t(:"errors.transfer_no_owner") end unless new_owner_membership raise CannotTransferToNonMember, Organizations.t(:"errors.transfer_to_non_member") end # New owner must be at least an admin (per README: "Ownership can be transferred to any admin") unless Roles.at_least?(new_owner_membership.role.to_sym, :admin) raise CannotTransferToNonAdmin, Organizations.t(:"errors.transfer_to_non_admin") end # No-op transfer to the current owner. return old_owner_membership if old_owner_membership.user_id == new_owner.id # Lock both memberships old_owner_membership.lock! new_owner_membership.lock! old_owner_user = old_owner_membership.user # Demote old owner to admin old_owner_membership.update!(role: "admin") # Promote new owner new_owner_membership.update!(role: "owner") Callbacks.dispatch( :ownership_transferred, organization: self, old_owner: old_owner_user, new_owner: new_owner ) end end |
#with_member ⇒ ActiveRecord::Relation
Find all organizations where a user is a member Uses efficient JOIN query
86 87 88 |
# File 'lib/organizations/models/organization.rb', line 86 scope :with_member, ->(user) { joins(:memberships).where(organizations_memberships: { user_id: user.id }) } |