rails_onboarding 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1351 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
  6. data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
  7. data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
  8. data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
  9. data/app/assets/javascripts/rails_onboarding/application.js +231 -0
  10. data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
  11. data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
  12. data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
  13. data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
  14. data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
  15. data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
  16. data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
  17. data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
  18. data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
  19. data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
  20. data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
  21. data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
  22. data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
  23. data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
  24. data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
  25. data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
  26. data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
  27. data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
  28. data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
  29. data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
  30. data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
  31. data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
  32. data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
  33. data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
  34. data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
  35. data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
  36. data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
  37. data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
  38. data/app/controllers/rails_onboarding/application_controller.rb +9 -0
  39. data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
  40. data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
  41. data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
  42. data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
  43. data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
  44. data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
  45. data/app/helpers/rails_onboarding/application_helper.rb +4 -0
  46. data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
  47. data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
  48. data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
  49. data/app/jobs/rails_onboarding/application_job.rb +4 -0
  50. data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
  51. data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
  52. data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
  53. data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
  54. data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
  55. data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
  56. data/app/models/rails_onboarding/analytics.rb +255 -0
  57. data/app/models/rails_onboarding/analytics_event.rb +168 -0
  58. data/app/models/rails_onboarding/application_record.rb +5 -0
  59. data/app/models/rails_onboarding/flow.rb +41 -0
  60. data/app/services/rails_onboarding/milestone_service.rb +105 -0
  61. data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
  62. data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
  63. data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
  64. data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
  65. data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
  66. data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
  67. data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
  68. data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
  69. data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
  70. data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
  71. data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
  72. data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
  73. data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
  74. data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
  75. data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
  76. data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
  77. data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
  78. data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
  79. data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
  80. data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
  81. data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
  82. data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
  83. data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
  84. data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
  85. data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
  86. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
  87. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
  88. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
  89. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
  90. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
  91. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
  92. data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
  93. data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
  94. data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
  95. data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
  96. data/app/views/rails_onboarding/templates/index.html.erb +255 -0
  97. data/config/importmap.rb +33 -0
  98. data/config/locales/en.yml +62 -0
  99. data/config/locales/es.yml +62 -0
  100. data/config/locales/fr.yml +62 -0
  101. data/config/routes.rb +79 -0
  102. data/lib/generators/rails_onboarding/install_generator.rb +124 -0
  103. data/lib/generators/rails_onboarding/templates/README +120 -0
  104. data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
  105. data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
  106. data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
  107. data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
  108. data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
  109. data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
  110. data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
  111. data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
  112. data/lib/generators/rails_onboarding/update_generator.rb +73 -0
  113. data/lib/rails_onboarding/api_mode.rb +367 -0
  114. data/lib/rails_onboarding/backfill.rb +130 -0
  115. data/lib/rails_onboarding/background_jobs.rb +412 -0
  116. data/lib/rails_onboarding/caching.rb +180 -0
  117. data/lib/rails_onboarding/cdn_support.rb +202 -0
  118. data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
  119. data/lib/rails_onboarding/configuration/analytics.rb +29 -0
  120. data/lib/rails_onboarding/configuration/integrations.rb +31 -0
  121. data/lib/rails_onboarding/configuration/milestones.rb +92 -0
  122. data/lib/rails_onboarding/configuration/personalization.rb +25 -0
  123. data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
  124. data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
  125. data/lib/rails_onboarding/configuration/steps.rb +121 -0
  126. data/lib/rails_onboarding/configuration/templates.rb +85 -0
  127. data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
  128. data/lib/rails_onboarding/configuration.rb +133 -0
  129. data/lib/rails_onboarding/configuration_errors.rb +19 -0
  130. data/lib/rails_onboarding/configuration_validator.rb +477 -0
  131. data/lib/rails_onboarding/controller_helpers.rb +153 -0
  132. data/lib/rails_onboarding/current.rb +13 -0
  133. data/lib/rails_onboarding/deprecation.rb +64 -0
  134. data/lib/rails_onboarding/devise_integration.rb +122 -0
  135. data/lib/rails_onboarding/engine.rb +245 -0
  136. data/lib/rails_onboarding/error_recovery.rb +173 -0
  137. data/lib/rails_onboarding/lazy_loading.rb +155 -0
  138. data/lib/rails_onboarding/multi_tenant.rb +306 -0
  139. data/lib/rails_onboarding/requirements_validator.rb +315 -0
  140. data/lib/rails_onboarding/responsive_helper.rb +218 -0
  141. data/lib/rails_onboarding/session_manager.rb +187 -0
  142. data/lib/rails_onboarding/skip_logic.rb +159 -0
  143. data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
  144. data/lib/rails_onboarding/version.rb +3 -0
  145. data/lib/rails_onboarding.rb +44 -0
  146. data/lib/tasks/rails_onboarding_analytics.rake +136 -0
  147. data/lib/tasks/rails_onboarding_tasks.rake +162 -0
  148. metadata +303 -0
@@ -0,0 +1,173 @@
1
+ module RailsOnboarding
2
+ # Error Recovery Service
3
+ # Handles failed steps gracefully with retry logic and error state management
4
+ class ErrorRecovery
5
+ MAX_RETRIES = 3
6
+ RETRY_DELAY = 2.seconds
7
+
8
+ class << self
9
+ # Execute a block with error recovery
10
+ #
11
+ # @param user [User] The user performing the action
12
+ # @param action [Symbol] The action being performed
13
+ # @param max_retries [Integer] Maximum number of retries
14
+ # @yield The block to execute
15
+ # @return [Boolean] True if successful, false otherwise
16
+ def with_recovery(user, action, max_retries: MAX_RETRIES, &block)
17
+ attempt = 0
18
+ last_error = nil
19
+
20
+ loop do
21
+ attempt += 1
22
+
23
+ begin
24
+ result = block.call
25
+ clear_error_state(user, action)
26
+ return result
27
+ rescue StandardError => e
28
+ last_error = e
29
+ Rails.logger.error("Error in #{action} (attempt #{attempt}/#{max_retries}): #{e.message}")
30
+ Rails.logger.error(e.backtrace.join("\n")) if Rails.env.development?
31
+
32
+ record_error(user, action, e, attempt)
33
+
34
+ break if attempt >= max_retries
35
+
36
+ sleep RETRY_DELAY if defined?(sleep)
37
+ end
38
+ end
39
+
40
+ # All retries failed
41
+ mark_as_failed(user, action, last_error)
42
+ false
43
+ end
44
+
45
+ # Record an error for analytics and debugging
46
+ def record_error(user, action, error, attempt)
47
+ return unless user && error
48
+
49
+ error_data = {
50
+ action: action.to_s,
51
+ error_class: error.class.name,
52
+ error_message: error.message,
53
+ attempt: attempt,
54
+ timestamp: Time.current.iso8601
55
+ }
56
+
57
+ # Store in user's error log if available
58
+ if user.respond_to?(:onboarding_errors)
59
+ user.onboarding_errors ||= []
60
+ user.onboarding_errors << error_data
61
+ user.save(validate: false) rescue nil
62
+ end
63
+
64
+ # Track in analytics
65
+ if defined?(RailsOnboarding::AnalyticsEvent)
66
+ RailsOnboarding::AnalyticsEvent.track_custom_event(
67
+ user: user,
68
+ event_name: "onboarding_error",
69
+ event_data: error_data
70
+ )
71
+ end
72
+ end
73
+
74
+ # Mark an action as failed after all retries
75
+ def mark_as_failed(user, action, error)
76
+ return unless user
77
+
78
+ if user.respond_to?(:onboarding_failed_actions)
79
+ user.onboarding_failed_actions ||= []
80
+ user.onboarding_failed_actions << {
81
+ action: action.to_s,
82
+ error: error&.message,
83
+ failed_at: Time.current.iso8601
84
+ }
85
+ user.save(validate: false) rescue nil
86
+ end
87
+
88
+ Rails.logger.error("Action #{action} failed for user #{user.id} after all retries")
89
+ end
90
+
91
+ # Clear error state for a successful action
92
+ def clear_error_state(user, action)
93
+ return unless user
94
+
95
+ if user.respond_to?(:onboarding_failed_actions)
96
+ user.onboarding_failed_actions ||= []
97
+ user.onboarding_failed_actions.reject! { |a| a[:action] == action.to_s }
98
+ user.save(validate: false) rescue nil
99
+ end
100
+ end
101
+
102
+ # Check if user has any failed actions
103
+ def has_errors?(user)
104
+ return false unless user&.respond_to?(:onboarding_failed_actions)
105
+ user.onboarding_failed_actions&.any? || false
106
+ end
107
+
108
+ # Get failed actions for a user
109
+ def failed_actions(user)
110
+ return [] unless user&.respond_to?(:onboarding_failed_actions)
111
+ user.onboarding_failed_actions || []
112
+ end
113
+
114
+ # Retry a failed action
115
+ def retry_failed_action(user, action, &block)
116
+ clear_error_state(user, action)
117
+ with_recovery(user, action, &block)
118
+ end
119
+
120
+ # Reset all errors for a user
121
+ def reset_errors(user)
122
+ return unless user
123
+
124
+ if user.respond_to?(:onboarding_errors=)
125
+ user.onboarding_errors = []
126
+ end
127
+
128
+ if user.respond_to?(:onboarding_failed_actions=)
129
+ user.onboarding_failed_actions = []
130
+ end
131
+
132
+ user.save(validate: false) rescue nil
133
+ end
134
+
135
+ # Retry with exponential backoff
136
+ def with_exponential_backoff(max_attempts: 3, base_delay: 1, max_delay: 30, exponential_base: 2)
137
+ attempt = 0
138
+ begin
139
+ attempt += 1
140
+ yield
141
+ rescue StandardError => e
142
+ if attempt < max_attempts
143
+ delay = [ base_delay * (exponential_base ** (attempt - 1)), max_delay ].min
144
+ Rails.logger.warn "Attempt #{attempt} failed: #{e.message}. Retrying in #{delay}s..."
145
+ sleep delay
146
+ retry
147
+ else
148
+ Rails.logger.error "All #{max_attempts} attempts failed: #{e.message}"
149
+ raise
150
+ end
151
+ end
152
+ end
153
+
154
+ # Execute with timeout
155
+ def with_timeout(seconds: 30)
156
+ Timeout.timeout(seconds) do
157
+ yield
158
+ end
159
+ rescue Timeout::Error => e
160
+ Rails.logger.error "Operation timed out after #{seconds} seconds"
161
+ raise
162
+ end
163
+
164
+ # Execute with fallback value
165
+ def with_fallback(fallback_value = nil)
166
+ yield
167
+ rescue StandardError => e
168
+ Rails.logger.warn "Operation failed, using fallback: #{e.message}"
169
+ fallback_value
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ # Lazy Loading module for performance optimization
5
+ #
6
+ # This module provides helpers for lazy loading onboarding components
7
+ # to improve initial page load times and reduce unnecessary processing
8
+ module LazyLoading
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ # Class-level configuration for lazy loading
13
+ class_attribute :lazy_loading_enabled, default: true
14
+ class_attribute :lazy_load_threshold, default: 1000 # Load data only when user count < threshold
15
+ end
16
+
17
+ class_methods do
18
+ # Scope for users who need onboarding (optimized query)
19
+ #
20
+ # @return [ActiveRecord::Relation]
21
+ def needs_onboarding
22
+ where(onboarding_completed: false)
23
+ .where("created_at > ?", 1.hour.ago)
24
+ end
25
+
26
+ # Scope for users currently in onboarding
27
+ #
28
+ # @return [ActiveRecord::Relation]
29
+ def in_onboarding
30
+ where(onboarding_completed: false)
31
+ .where.not(onboarding_current_step: nil)
32
+ end
33
+
34
+ # Batch load onboarding states for multiple users
35
+ # Reduces N+1 queries when displaying lists
36
+ #
37
+ # @param user_ids [Array<Integer>] User IDs to load
38
+ # @return [Hash] Hash of user_id => onboarding_state
39
+ def batch_load_onboarding_states(user_ids)
40
+ return {} if user_ids.empty?
41
+
42
+ where(id: user_ids)
43
+ .pluck(:id, :onboarding_completed, :onboarding_current_step)
44
+ .each_with_object({}) do |(id, completed, step), hash|
45
+ hash[id] = {
46
+ completed: completed,
47
+ current_step: step
48
+ }
49
+ end
50
+ end
51
+
52
+ # Count users in each onboarding step (cached for performance)
53
+ #
54
+ # @param ttl [Integer] Cache TTL in seconds
55
+ # @return [Hash] Step name => count
56
+ def onboarding_step_counts(ttl: 300)
57
+ cache_key = "rails_onboarding:step_counts"
58
+ Rails.cache.fetch(cache_key, expires_in: ttl) do
59
+ in_onboarding
60
+ .group(:onboarding_current_step)
61
+ .count
62
+ end
63
+ end
64
+ end
65
+
66
+ # Lazy load current step data only when needed
67
+ #
68
+ # @return [Hash, nil] Step data
69
+ def lazy_current_step
70
+ return @lazy_current_step if defined?(@lazy_current_step)
71
+
72
+ @lazy_current_step = if lazy_load_enabled?
73
+ current_onboarding_step
74
+ else
75
+ nil
76
+ end
77
+ end
78
+
79
+ # Lazy load next step data only when needed
80
+ #
81
+ # @return [Hash, nil] Next step data
82
+ def lazy_next_step
83
+ return @lazy_next_step if defined?(@lazy_next_step)
84
+
85
+ @lazy_next_step = if lazy_load_enabled?
86
+ next_onboarding_step
87
+ else
88
+ nil
89
+ end
90
+ end
91
+
92
+ # Lazy load available milestones
93
+ #
94
+ # @return [Array<Hash>] Available milestones
95
+ def lazy_milestones_available
96
+ return @lazy_milestones_available if defined?(@lazy_milestones_available)
97
+
98
+ @lazy_milestones_available = if lazy_load_enabled? && RailsOnboarding.configuration.enable_milestones
99
+ milestones_available
100
+ else
101
+ []
102
+ end
103
+ end
104
+
105
+ # Lazy load tooltips only when on a page that needs them
106
+ #
107
+ # @param page_context [String] Current page or controller action
108
+ # @return [Hash] Available tooltips for current page
109
+ def lazy_tooltips_for_page(page_context)
110
+ return {} unless lazy_load_enabled?
111
+ return {} unless RailsOnboarding.configuration.enable_tooltips
112
+
113
+ cache_key = "rails_onboarding:user:#{id}:tooltips:#{page_context}"
114
+ Rails.cache.fetch(cache_key, expires_in: 600) do
115
+ tooltips = {}
116
+ RailsOnboarding.configuration.feature_tooltips.each do |feature, config|
117
+ # Only include tooltips relevant to current page
118
+ if feature.include?(page_context) && show_feature_tooltip?(feature)
119
+ tooltips[feature] = config
120
+ end
121
+ end
122
+ tooltips
123
+ end
124
+ end
125
+
126
+ # Check if lazy loading should be enabled for this user
127
+ # based on both the enabled flag and user count threshold
128
+ #
129
+ # @return [Boolean]
130
+ def lazy_load_enabled?
131
+ return false unless self.class.lazy_loading_enabled
132
+
133
+ # Use ActiveRecord's count with uncached query to ensure fresh count
134
+ # This works better with Rails test transactions
135
+ count = self.class.uncached { self.class.count }
136
+
137
+ count < self.class.lazy_load_threshold
138
+ end
139
+
140
+ # Preload onboarding data for this user
141
+ # Call this in controller before rendering to avoid N+1
142
+ #
143
+ # @return [Hash] Preloaded data
144
+ def preload_onboarding_data
145
+ {
146
+ needs_onboarding: needs_onboarding?,
147
+ current_step: current_onboarding_step,
148
+ next_step: next_onboarding_step,
149
+ progress: onboarding_progress,
150
+ milestones: RailsOnboarding.configuration.enable_milestones ? achieved_milestones : [],
151
+ milestone_points: RailsOnboarding.configuration.enable_milestones ? total_milestone_points : 0
152
+ }
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,306 @@
1
+ module RailsOnboarding
2
+ # Multi-Tenant Support
3
+ # Allows different onboarding configurations per organization/tenant
4
+ class MultiTenant
5
+ class << self
6
+ # Storage for organization-specific configurations (by ID)
7
+ def organization_configurations
8
+ @organization_configurations ||= {}
9
+ end
10
+
11
+ # Merged configuration is cached per organization ID so repeated lookups
12
+ # (every onboarding request) don't re-merge the same hash every time.
13
+ # Invalidated whenever that organization is reconfigured or cleared.
14
+ def merged_configuration_cache
15
+ @merged_configuration_cache ||= {}
16
+ end
17
+
18
+ # Configure onboarding for a specific organization by ID
19
+ #
20
+ # @param organization_id [Integer, String] The organization ID
21
+ # @yield [config] Configuration object to customize
22
+ def configure_for_organization(organization_id)
23
+ config = OrganizationConfig.new(organization_id)
24
+ yield config if block_given?
25
+ organization_configurations[organization_id] = config.to_hash
26
+ merged_configuration_cache.delete(organization_id)
27
+ end
28
+
29
+ # Clear all organization configurations (useful for testing)
30
+ def clear_all_configurations
31
+ @organization_configurations = {}
32
+ @merged_configuration_cache = {}
33
+ end
34
+
35
+ # Get configuration for a specific tenant
36
+ #
37
+ # @param tenant [Object, Integer, String] The tenant object or organization ID
38
+ # @return [Hash] Tenant-specific configuration
39
+ def configuration_for(tenant)
40
+ return default_configuration unless tenant
41
+
42
+ # Check if tenant is an ID (for organization_configurations lookup)
43
+ if tenant.is_a?(Integer) || tenant.is_a?(String)
44
+ return merged_configuration_cache[tenant] ||= begin
45
+ stored_config = organization_configurations[tenant]
46
+ stored_config ? merge_configurations(default_configuration, stored_config) : default_configuration
47
+ end
48
+ end
49
+
50
+ # Check if tenant has custom configuration
51
+ tenant_config = if tenant.respond_to?(:onboarding_configuration)
52
+ tenant.onboarding_configuration
53
+ elsif tenant.respond_to?(:onboarding_config)
54
+ tenant.onboarding_config
55
+ end
56
+
57
+ return default_configuration unless tenant_config
58
+
59
+ # Merge with default configuration
60
+ merge_configurations(default_configuration, parse_config(tenant_config))
61
+ end
62
+
63
+ # Get steps for a specific tenant
64
+ #
65
+ # @param tenant [Object] The tenant object
66
+ # @return [Array<Hash>] Array of step configurations
67
+ def steps_for(tenant)
68
+ config = configuration_for(tenant)
69
+ config[:steps] || RailsOnboarding.configuration.steps
70
+ end
71
+
72
+ # Get feature tooltips for a specific tenant
73
+ #
74
+ # @param tenant [Object] The tenant object
75
+ # @return [Hash] Feature tooltip configurations
76
+ def tooltips_for(tenant)
77
+ config = configuration_for(tenant)
78
+ config[:feature_tooltips] || RailsOnboarding.configuration.feature_tooltips
79
+ end
80
+
81
+ # Get milestones for a specific tenant
82
+ #
83
+ # @param tenant [Object] The tenant object
84
+ # @return [Array<Hash>] Milestone configurations
85
+ def milestones_for(tenant)
86
+ config = configuration_for(tenant)
87
+ config[:milestones] || RailsOnboarding.configuration.milestones
88
+ end
89
+
90
+ # Check if a feature is enabled for a tenant
91
+ #
92
+ # @param tenant [Object] The tenant object
93
+ # @param feature [Symbol] The feature to check
94
+ # @return [Boolean] True if feature is enabled
95
+ def feature_enabled?(tenant, feature)
96
+ config = configuration_for(tenant)
97
+
98
+ case feature
99
+ when :tooltips
100
+ config[:enable_tooltips] != false
101
+ when :milestones
102
+ config[:enable_milestones] != false
103
+ when :analytics
104
+ config[:enable_analytics] != false
105
+ when :ab_testing
106
+ config[:enable_ab_testing] == true
107
+ when :personalization
108
+ config[:personalization_enabled] == true
109
+ else
110
+ false
111
+ end
112
+ end
113
+
114
+ # Apply tenant configuration to current context, for the duration of the
115
+ # block only. This does NOT touch RailsOnboarding.configuration itself -
116
+ # it sets a thread/fiber-local override (RailsOnboarding::Current) that
117
+ # Configuration's readers consult first, so concurrent requests for
118
+ # other tenants (or no tenant) are never affected, and everything is
119
+ # automatically cleared even if this method's own `ensure` never ran
120
+ # (Rails resets CurrentAttributes around every request and job).
121
+ #
122
+ # @param tenant [Object] The tenant object
123
+ # @yield Block to execute with tenant configuration
124
+ def with_tenant_configuration(tenant)
125
+ return yield unless tenant
126
+
127
+ previous_overrides = RailsOnboarding::Current.tenant_overrides
128
+ RailsOnboarding::Current.tenant_overrides = tenant_config_overrides(tenant)
129
+
130
+ yield
131
+ ensure
132
+ RailsOnboarding::Current.tenant_overrides = previous_overrides
133
+ end
134
+
135
+ # Get organization ID for user
136
+ #
137
+ # @param user [User] The user object
138
+ # @return [Integer, String, nil] The organization ID
139
+ def organization_for_user(user)
140
+ return nil unless user
141
+
142
+ # Try to get organization_id directly
143
+ if user.respond_to?(:organization_id)
144
+ return user.organization_id
145
+ end
146
+
147
+ # Otherwise, get tenant and extract its ID
148
+ tenant = tenant_from_user(user)
149
+ tenant&.id
150
+ end
151
+
152
+ # Get tenant from user
153
+ #
154
+ # @param user [User] The user object
155
+ # @return [Object, nil] The tenant object
156
+ def tenant_from_user(user)
157
+ return nil unless user
158
+
159
+ # Try common tenant associations
160
+ if user.respond_to?(:organization)
161
+ user.organization
162
+ elsif user.respond_to?(:account)
163
+ user.account
164
+ elsif user.respond_to?(:tenant)
165
+ user.tenant
166
+ elsif user.respond_to?(:team)
167
+ user.team
168
+ elsif user.respond_to?(:company)
169
+ user.company
170
+ end
171
+ end
172
+
173
+ # Set custom configuration for a tenant
174
+ #
175
+ # @param tenant [Object] The tenant object
176
+ # @param config [Hash] Configuration hash
177
+ def set_configuration(tenant, config)
178
+ return unless tenant
179
+
180
+ if tenant.respond_to?(:onboarding_configuration=)
181
+ tenant.onboarding_configuration = config.to_json
182
+ tenant.save
183
+ elsif tenant.respond_to?(:update)
184
+ tenant.update(onboarding_configuration: config.to_json) rescue nil
185
+ end
186
+ end
187
+
188
+ # Copy configuration from one tenant to another
189
+ #
190
+ # @param source_tenant [Object] Source tenant
191
+ # @param target_tenant [Object] Target tenant
192
+ def copy_configuration(source_tenant, target_tenant)
193
+ config = configuration_for(source_tenant)
194
+ set_configuration(target_tenant, config)
195
+ end
196
+
197
+ # Reset tenant configuration to default
198
+ #
199
+ # @param tenant [Object] The tenant object
200
+ def reset_configuration(tenant)
201
+ return unless tenant
202
+
203
+ if tenant.respond_to?(:onboarding_configuration=)
204
+ tenant.onboarding_configuration = nil
205
+ tenant.save
206
+ end
207
+ end
208
+
209
+ private
210
+
211
+ def default_configuration
212
+ {
213
+ steps: RailsOnboarding.configuration.steps,
214
+ feature_tooltips: RailsOnboarding.configuration.feature_tooltips,
215
+ milestones: RailsOnboarding.configuration.milestones,
216
+ enable_tooltips: RailsOnboarding.configuration.enable_tooltips,
217
+ enable_milestones: RailsOnboarding.configuration.enable_milestones,
218
+ enable_analytics: RailsOnboarding.configuration.enable_analytics,
219
+ enable_ab_testing: RailsOnboarding.configuration.enable_ab_testing,
220
+ personalization_enabled: RailsOnboarding.configuration.personalization_enabled,
221
+ redirect_after_completion: RailsOnboarding.configuration.redirect_after_completion,
222
+ redirect_after_skip: RailsOnboarding.configuration.redirect_after_skip
223
+ }
224
+ end
225
+
226
+ def parse_config(config_data)
227
+ return {} unless config_data
228
+
229
+ parsed = if config_data.is_a?(String)
230
+ JSON.parse(config_data)
231
+ else
232
+ config_data
233
+ end
234
+
235
+ parsed.deep_symbolize_keys
236
+ rescue JSON::ParserError => e
237
+ Rails.logger.error("Failed to parse tenant configuration: #{e.message}")
238
+ {}
239
+ end
240
+
241
+ def merge_configurations(base, override)
242
+ base.deep_merge(override) do |key, base_val, override_val|
243
+ # For arrays, use override completely
244
+ if base_val.is_a?(Array) && override_val.is_a?(Array)
245
+ override_val
246
+ else
247
+ override_val.nil? ? base_val : override_val
248
+ end
249
+ end
250
+ end
251
+
252
+ # Builds the RailsOnboarding::Current override hash for +tenant+, only
253
+ # including keys that should actually override the process-wide config -
254
+ # mirrors the guards the old mutation-based implementation used, so a
255
+ # tenant config that hasn't customized a given setting still falls
256
+ # through to the global default instead of overriding it with nil/false.
257
+ def tenant_config_overrides(tenant)
258
+ config = configuration_for(tenant)
259
+ overrides = {}
260
+
261
+ overrides[:steps] = config[:steps] if config[:steps]
262
+ overrides[:feature_tooltips] = config[:feature_tooltips] if config[:feature_tooltips]
263
+ overrides[:milestones] = config[:milestones] if config[:milestones]
264
+ overrides[:enable_tooltips] = config[:enable_tooltips] unless config[:enable_tooltips].nil?
265
+ overrides[:enable_milestones] = config[:enable_milestones] unless config[:enable_milestones].nil?
266
+
267
+ overrides
268
+ end
269
+ end
270
+ end
271
+
272
+ # Configuration object for organization-specific settings
273
+ class OrganizationConfig
274
+ attr_accessor :steps, :feature_tooltips, :milestones,
275
+ :enable_tooltips, :enable_milestones, :enable_analytics,
276
+ :enable_ab_testing, :personalization_enabled,
277
+ :redirect_after_completion, :redirect_after_skip
278
+
279
+ def initialize(organization_id)
280
+ @organization_id = organization_id
281
+ @steps = []
282
+ @feature_tooltips = {}
283
+ @milestones = {}
284
+ @enable_tooltips = true
285
+ @enable_milestones = true
286
+ @enable_analytics = true
287
+ @enable_ab_testing = false
288
+ @personalization_enabled = false
289
+ end
290
+
291
+ def to_hash
292
+ {
293
+ steps: @steps,
294
+ feature_tooltips: @feature_tooltips,
295
+ milestones: @milestones,
296
+ enable_tooltips: @enable_tooltips,
297
+ enable_milestones: @enable_milestones,
298
+ enable_analytics: @enable_analytics,
299
+ enable_ab_testing: @enable_ab_testing,
300
+ personalization_enabled: @personalization_enabled,
301
+ redirect_after_completion: @redirect_after_completion,
302
+ redirect_after_skip: @redirect_after_skip
303
+ }
304
+ end
305
+ end
306
+ end