Class: Organizations::JoinFlow
- Inherits:
-
Object
- Object
- Organizations::JoinFlow
- 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.)
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
-
.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!).
Instance Method Summary collapse
- #attempt(code: nil, email: nil, verification_code: nil, message: nil) ⇒ Object
-
#initialize(user:, organization:) ⇒ JoinFlow
constructor
rubocop:enable Metrics/ParameterLists.
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
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: ) 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 account_email_shortcut if account_email_eligible? plain_request() 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.) end |