Class: Organizations::Organization

Inherits:
ActiveRecord::Base
  • Object
show all
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.

Examples:

Creating an organization

org = Organization.create!(name: "Acme Corp")

Adding members

org.add_member!(user, role: :admin)

Querying members

org.owner        # => User (the owner)
org.admins       # => [User, User] (admins including owner)
org.member_count # => 5

Defined Under Namespace

Classes: CannotDemoteOwner, CannotHaveMultipleOwners, CannotInviteAsOwner, CannotRemoveOwner, CannotTransferToNonAdmin, CannotTransferToNonMember, MemberAlreadyExists, NoOwnerPresent

Class Method Summary collapse

Instance Method Summary collapse

Methods included from MetadataFlags

metadata_flag

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.

Parameters:

  • owner (User)

    becomes the owner (single-owner invariant holds)

  • attributes (Hash)

    organization attributes (name:, plus any host columns)

Returns:

Raises:

  • (ArgumentError)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.

Parameters:

  • domain (String)

    e.g. "inizio.com" (exact match — subdomains must be enrolled separately)

  • membership_metadata (Hash) (defaults to: {})

    copied onto memberships created through this domain

Returns:



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

Parameters:

  • user (User)

    The user to add

  • role (Symbol) (defaults to: :member)

    The role (default: :member)

Returns:

  • (Membership)

    The created or existing membership

Raises:



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

#adminsActiveRecord::Relation<User>

Get all admins (users with admin role or higher) Uses efficient JOIN query to avoid N+1

Returns:

  • (ActiveRecord::Relation<User>)


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

Parameters:

  • join_request (JoinRequest)
  • approved_by (User, nil) (defaults to: nil)

Returns:



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

Parameters:

  • user (User)

    The user whose role to change

  • to (Symbol)

    The new role

  • changed_by (User, nil) (defaults to: nil)

    Who is making the change (for callbacks)

Returns:

Raises:

  • (CannotHaveMultipleOwners)

    if promoting to owner when one exists

  • (CannotRemoveLastOwner)

    if demoting the only owner



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

Parameters:

  • label (String, nil) (defaults to: nil)

    campaign attribution ("cafeteria poster")

  • requires_verified_domain_email (Boolean) (defaults to: false)

    chain the emailed-code challenge ("reinforced" level)

  • auto_approve (Boolean) (defaults to: true)

    false parks redemptions as pending requests for manual approval

  • expires_at (Time, nil) (defaults to: nil)
  • max_uses (Integer, nil) (defaults to: nil)
  • created_by (User, nil) (defaults to: nil)
  • membership_metadata (Hash) (defaults to: {})

    copied onto memberships created through this code

Returns:



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

Returns:

  • (Boolean)


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

Parameters:

  • user (User)

    The user to check

Returns:

  • (Boolean)


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

Parameters:

  • emails (Enumerable<String>)
  • source (String, nil) (defaults to: nil)

    provenance tag ("csv_2026-07")

  • membership_metadata (Hash) (defaults to: {})

    copied onto memberships created through these entries

Returns:



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.

Parameters:

  • user (User)

    must respond to #email; #confirmed_at gates trust

Returns:

Raises:



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 (user)
  unless Organizations.configuration.
    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_countInteger

Get member count from the organizations counter cache.

Returns:

  • (Integer)


174
175
176
# File 'lib/organizations/models/organization.rb', line 174

def member_count
  memberships_count || 0
end

#ownerUser?

Get the owner of this organization

Returns:

  • (User, nil)


132
133
134
# File 'lib/organizations/models/organization.rb', line 132

def owner
  owner_membership&.user
end

#owner_membershipMembership?

Get the owner's membership

Returns:



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_invitationsActiveRecord::Relation<Invitation>

Get pending invitations

Returns:



180
181
182
# File 'lib/organizations/models/organization.rb', line 180

def pending_invitations
  invitations.pending
end

#pending_join_requestsActiveRecord::Relation<JoinRequest>

Open join requests awaiting a decision

Returns:



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

Parameters:

  • join_request (JoinRequest)
  • rejected_by (User, nil) (defaults to: nil)
  • reason (String, nil) (defaults to: nil)

Returns:



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

Parameters:

  • user (User)

    The user to remove

  • removed_by (User, nil) (defaults to: nil)

    Who is removing them (for callbacks)

Raises:



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

Parameters:

  • email (String)

    Email address to invite

  • invited_by (User, nil) (defaults to: nil)

    Who is sending the invitation (uses Current.user if not provided)

  • role (Symbol) (defaults to: nil)

    Role for the invitation (default: from config)

Returns:

  • (Invitation)

    The created or existing invitation

Raises:



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

  authorize_inviter!(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

Parameters:

  • new_owner (User)

    The user to become owner

Raises:



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_memberActiveRecord::Relation

Find all organizations where a user is a member Uses efficient JOIN query

Parameters:

  • user (User)

    The user

Returns:

  • (ActiveRecord::Relation)


86
87
88
# File 'lib/organizations/models/organization.rb', line 86

scope :with_member, ->(user) {
  joins(:memberships).where(organizations_memberships: { user_id: user.id })
}