Class: Organizations::JoinFlow

Inherits:
Object
  • Object
show all
Defined in:
lib/organizations/join_flow.rb

Overview

The controller-facing facade over verified joining: ONE call that says "this user is trying to join this organization with whatever they gave us", returning a Result instead of raising — so host controllers render states instead of writing ten-branch rescue ladders.

The model APIs (JoinCode.redeem, JoinRequest#start_email_verification!, …) remain the exception-raising programmatic layer; JoinFlow is the skin every join UI ends up needing. Before this existed, the first production host's "JoinService" was ~60% this exact translation, rewritten by hand.

Dispatch order (first present input wins):

verification_code → verify the emailed 6-digit code
code              → redeem a join code (PIN)
email             → start/restart the emailed challenge for an address
(none)            → confirmed-account-email shortcut when eligible,
                  else a plain request-to-join

Result contract:

outcome — :member | :challenge_sent | :pending | :vetoed | :error
reason  — nil on success; on :vetoed/:error a stable symbol from
        REASONS (build your copy off this, never off the message)
message — localized human string (the caught error's message, already
        resolved through the gem's i18n catalog — override per key
        in your locale files, or ignore it and map reason yourself)
membership / join_request — the record backing the outcome

⚠️ SECURITY (host responsibilities that CANNOT live in the gem):

- Rate-limit the endpoints that call this (code redemption and
verification are enumeration surfaces) — see README "Rate limiting
your join endpoints".
- :join_code_invalid deliberately covers unknown, revoked, expired AND
foreign-organization codes with one reason — never tell users which
codes exist. (Known narrow residual: response SHAPE is identical, but
an expired org-scoped code does slightly more work than the
fast-reject paths — it reaches redeem!'s row lock before raising — a
timing side-channel your endpoint rate limits should render moot.)

Examples:

A join endpoint in three lines

result = Organizations::JoinFlow.attempt(
  user: current_user, organization: @org,
  code: params[:code], email: params[:email], message: params[:message]
)
result.member? ? redirect_to(@org) : render_state(result)

Defined Under Namespace

Classes: Result

Constant Summary collapse

REASONS =

Stable machine-readable reasons a host can switch on.

%i[
  join_code_invalid
  join_code_exhausted
  email_not_eligible
  email_already_claimed
  throttled
  verification_code_invalid
  verification_code_expired
  verification_code_missing
  verification_attempts_exceeded
  request_closed
  not_accepting_requests
  membership_vetoed
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(user:, organization:) ⇒ JoinFlow

rubocop:enable Metrics/ParameterLists



94
95
96
97
# File 'lib/organizations/join_flow.rb', line 94

def initialize(user:, organization:)
  @user = user
  @organization = organization
end

Class Method Details

.attempt(user:, organization:, code: nil, email: nil, verification_code: nil, message: nil) ⇒ Result

Every parameter is an optional keyword with a safe default — this IS the public API surface, not incidental complexity (same posture as Organization#generate_join_code!). rubocop:disable Metrics/ParameterLists

Parameters:

  • user (User)

    the joining user (required)

  • organization (Organizations::Organization)

    the org being joined (required — codes from OTHER organizations resolve to :join_code_invalid on purpose; for organization-less global redemption call Organizations::JoinCode.redeem directly)

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

    a join code (PIN) as typed

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

    address for the emailed challenge

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

    the emailed 6-digit code as typed

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

    optional note for manual approval requests

Returns:



88
89
90
91
# File 'lib/organizations/join_flow.rb', line 88

def self.attempt(user:, organization:, code: nil, email: nil, verification_code: nil, message: nil)
  new(user: user, organization: organization)
    .attempt(code: code, email: email, verification_code: verification_code, message: message)
end

Instance Method Details

#attempt(code: nil, email: nil, verification_code: nil, message: nil) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/organizations/join_flow.rb', line 99

def attempt(code: nil, email: nil, verification_code: nil, message: nil)
  # Already a member: every input resolves to the same quiet success —
  # never consume a code use or mint a request for someone who's in.
  if (existing = organization.memberships.find_by(user_id: user.id))
    return Result.new(outcome: :member, membership: existing)
  end

  return verify_emailed_code(verification_code) if verification_code.present?
  return redeem_code(code) if code.present?
  return start_challenge(email) if email.present?
  return  if 

  plain_request(message)
rescue Organizations::MembershipVetoed => e
  # The host's on_member_joining gate said no — a first-class outcome,
  # not a generic error (hosts usually show cap-specific copy + support).
  Result.new(outcome: :vetoed, reason: :membership_vetoed, message: e.message)
end