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,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ # API Mode support for headless/API-only applications
5
+ # Provides JSON API endpoints for mobile apps and SPAs
6
+ module ApiMode
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ # Skip CSRF for API requests (only works with ActionController classes)
11
+ begin
12
+ skip_before_action :verify_authenticity_token, if: :api_request?, raise: false
13
+ rescue NoMethodError
14
+ # Not an ActionController class, skip this setup
15
+ end
16
+
17
+ # Set API response format (only works with ActionController classes)
18
+ begin
19
+ before_action :set_api_response_format, if: :api_request?
20
+ rescue NoMethodError
21
+ # Not an ActionController class, skip this setup
22
+ end
23
+
24
+ # Error handling (only works with ActionController classes)
25
+ begin
26
+ rescue_from StandardError, with: :handle_api_error if :api_request?
27
+ rescue NoMethodError
28
+ # Not an ActionController class, skip this setup
29
+ end
30
+ end
31
+
32
+ module ClassMethods
33
+ # Enable API mode for specific actions
34
+ def enable_api_mode(options = {})
35
+ @api_mode_options = {
36
+ version: options.fetch(:version, "v1"),
37
+ authentication: options.fetch(:authentication, :token),
38
+ serializer: options.fetch(:serializer, :active_model_serializers),
39
+ rate_limiting: options.fetch(:rate_limiting, true),
40
+ pagination: options.fetch(:pagination, true)
41
+ }.merge(options)
42
+ end
43
+
44
+ def api_mode_options
45
+ @api_mode_options || {}
46
+ end
47
+
48
+ # Check if API mode is enabled
49
+ def api_mode_enabled?
50
+ @api_mode_options.present?
51
+ end
52
+ end
53
+
54
+ # Detect API requests
55
+ def api_request?
56
+ request.format.json? ||
57
+ request.path.start_with?("/api/") ||
58
+ request.headers["Accept"]&.include?("application/json") ||
59
+ request.headers["Content-Type"]&.include?("application/json")
60
+ end
61
+
62
+ # API response helpers
63
+ def render_api_success(data, status: :ok, meta: {})
64
+ render json: {
65
+ success: true,
66
+ data: serialize_for_api(data),
67
+ meta: build_api_meta(meta)
68
+ }, status: status
69
+ end
70
+
71
+ def render_api_error(message, status: :unprocessable_entity, errors: {})
72
+ render json: {
73
+ success: false,
74
+ error: {
75
+ message: message,
76
+ details: errors
77
+ },
78
+ meta: build_api_meta
79
+ }, status: status
80
+ end
81
+
82
+ # Onboarding-specific API methods
83
+ def api_onboarding_status
84
+ render_api_success({
85
+ onboarding_completed: current_user.onboarding_completed?,
86
+ onboarding_skipped: current_user.onboarding_skipped?,
87
+ current_step: current_user.onboarding_current_step,
88
+ progress_percentage: current_user.onboarding_progress_percentage,
89
+ steps: api_format_steps,
90
+ next_step: api_next_step,
91
+ previous_step: api_previous_step
92
+ })
93
+ end
94
+
95
+ def api_complete_step
96
+ step_name = params[:step_name] || params[:step]
97
+
98
+ if current_user.complete_step(step_name)
99
+ render_api_success({
100
+ message: "Step '#{step_name}' completed successfully",
101
+ current_step: current_user.onboarding_current_step,
102
+ progress_percentage: current_user.onboarding_progress_percentage,
103
+ onboarding_completed: current_user.onboarding_completed?
104
+ })
105
+ else
106
+ render_api_error("Failed to complete step '#{step_name}'", errors: current_user.errors.full_messages)
107
+ end
108
+ end
109
+
110
+ def api_skip_step
111
+ step_name = params[:step_name] || params[:step]
112
+
113
+ if current_user.skip_step(step_name)
114
+ render_api_success({
115
+ message: "Step '#{step_name}' skipped successfully",
116
+ current_step: current_user.onboarding_current_step,
117
+ progress_percentage: current_user.onboarding_progress_percentage
118
+ })
119
+ else
120
+ render_api_error("Failed to skip step '#{step_name}'", errors: current_user.errors.full_messages)
121
+ end
122
+ end
123
+
124
+ def api_complete_onboarding
125
+ if current_user.complete_onboarding!
126
+ render_api_success({
127
+ message: "Onboarding completed successfully",
128
+ completed_at: current_user.onboarding_completed_at
129
+ })
130
+ else
131
+ render_api_error("Failed to complete onboarding", errors: current_user.errors.full_messages)
132
+ end
133
+ end
134
+
135
+ def api_restart_onboarding
136
+ if current_user.restart_onboarding!
137
+ render_api_success({
138
+ message: "Onboarding restarted successfully",
139
+ current_step: current_user.onboarding_current_step,
140
+ progress_percentage: 0
141
+ })
142
+ else
143
+ render_api_error("Failed to restart onboarding", errors: current_user.errors.full_messages)
144
+ end
145
+ end
146
+
147
+ def api_tooltips_list
148
+ tooltips = current_user.available_tooltips
149
+
150
+ render_api_success({
151
+ tooltips: tooltips.map do |tooltip|
152
+ {
153
+ id: tooltip[:id],
154
+ title: tooltip[:title],
155
+ content: tooltip[:content],
156
+ target: tooltip[:target],
157
+ position: tooltip[:position],
158
+ shown: current_user.tooltip_shown?(tooltip[:id])
159
+ }
160
+ end
161
+ })
162
+ end
163
+
164
+ def api_dismiss_tooltip
165
+ tooltip_id = params[:tooltip_id]
166
+
167
+ if current_user.dismiss_tooltip(tooltip_id)
168
+ render_api_success({
169
+ message: "Tooltip dismissed successfully",
170
+ tooltip_id: tooltip_id
171
+ })
172
+ else
173
+ render_api_error("Failed to dismiss tooltip")
174
+ end
175
+ end
176
+
177
+ # API authentication
178
+ def authenticate_api_request!
179
+ return if current_user.present?
180
+
181
+ token = extract_api_token
182
+
183
+ if token.blank?
184
+ render_api_error("Missing authentication token", status: :unauthorized)
185
+ return
186
+ end
187
+
188
+ user = authenticate_with_token(token)
189
+
190
+ if user
191
+ # Set current_user for the request
192
+ instance_variable_set(:@current_user, user)
193
+ else
194
+ render_api_error("Invalid authentication token", status: :unauthorized)
195
+ end
196
+ end
197
+
198
+ # Current user accessor for API requests
199
+ def current_user
200
+ @current_user ||= begin
201
+ token = extract_api_token
202
+ authenticate_with_token(token) if token.present?
203
+ end
204
+ end
205
+
206
+ private
207
+
208
+ # Serialize data for API response
209
+ def serialize_for_api(data)
210
+ case self.class.api_mode_options[:serializer]
211
+ when :active_model_serializers
212
+ # Use ActiveModel::Serializers if available
213
+ if defined?(ActiveModel::Serializer)
214
+ ActiveModelSerializers::SerializableResource.new(data).as_json
215
+ else
216
+ data.as_json
217
+ end
218
+ when :jsonapi
219
+ # Use JSONAPI::Serializer if available
220
+ if defined?(JSONAPI::Serializer)
221
+ data.to_json
222
+ else
223
+ data.as_json
224
+ end
225
+ else
226
+ # Default to as_json
227
+ data.respond_to?(:as_json) ? data.as_json : data
228
+ end
229
+ end
230
+
231
+ # Build API metadata
232
+ def build_api_meta(custom_meta = {})
233
+ {
234
+ timestamp: Time.current.iso8601,
235
+ version: self.class.api_mode_options[:version] || "v1",
236
+ request_id: request.request_id
237
+ }.merge(custom_meta)
238
+ end
239
+
240
+ # Format steps for API
241
+ def api_format_steps
242
+ RailsOnboarding.configuration.steps.map do |step|
243
+ {
244
+ name: step[:name],
245
+ title: step[:title],
246
+ description: step[:description],
247
+ icon: step[:icon],
248
+ skippable: step[:skippable],
249
+ completed: current_user.step_completed?(step[:name])
250
+ }
251
+ end
252
+ end
253
+
254
+ # Get next step for API
255
+ def api_next_step
256
+ next_step = current_user.next_step
257
+ return nil unless next_step
258
+
259
+ {
260
+ name: next_step[:name],
261
+ title: next_step[:title],
262
+ description: next_step[:description]
263
+ }
264
+ end
265
+
266
+ # Get previous step for API
267
+ def api_previous_step
268
+ prev_step = current_user.previous_step
269
+ return nil unless prev_step
270
+
271
+ {
272
+ name: prev_step[:name],
273
+ title: prev_step[:title],
274
+ description: prev_step[:description]
275
+ }
276
+ end
277
+
278
+ # Extract the API token from the request. Tokens are only read from
279
+ # request headers, never from query parameters: a token in the URL leaks
280
+ # into server and proxy access logs, browser history, and the Referer
281
+ # header sent to third parties.
282
+ def extract_api_token
283
+ authorization = request.headers["Authorization"]
284
+ # Authorization: Bearer <token> (also tolerates a bare token value)
285
+ return authorization.split(" ").last if authorization.present?
286
+
287
+ # Custom header for clients that can't set Authorization
288
+ request.headers["X-API-Token"].presence
289
+ end
290
+
291
+ # Authenticate user with token
292
+ def authenticate_with_token(token)
293
+ # This should be implemented by the host application
294
+ # Default implementation uses a simple token check
295
+ user_class = RailsOnboarding.configuration.user_class_name.constantize
296
+
297
+ if user_class.respond_to?(:find_by_api_token)
298
+ user_class.find_by_api_token(token)
299
+ elsif user_class.column_names.include?("api_token")
300
+ user_class.find_by(api_token: token)
301
+ else
302
+ # Raise error if no token authentication method is available
303
+ raise NotImplementedError,
304
+ "API token authentication not configured. Please add an 'api_token' column to your User model " \
305
+ "or implement a custom 'find_by_api_token' class method. See documentation for more details."
306
+ end
307
+ end
308
+
309
+ # Set API response format
310
+ def set_api_response_format
311
+ request.format = :json
312
+ end
313
+
314
+ # Handle API errors
315
+ def handle_api_error(exception)
316
+ Rails.logger.error("API Error: #{exception.class} - #{exception.message}")
317
+ Rails.logger.error(exception.backtrace.join("\n"))
318
+
319
+ render_api_error(
320
+ "An error occurred: #{exception.message}",
321
+ status: :internal_server_error
322
+ )
323
+ end
324
+ end
325
+
326
+ # API Controller for dedicated API endpoints
327
+ class ApiController < ActionController::API
328
+ include RailsOnboarding::ApiMode
329
+
330
+ before_action :authenticate_api_request!
331
+
332
+ # GET /api/onboarding/status
333
+ def status
334
+ api_onboarding_status
335
+ end
336
+
337
+ # POST /api/onboarding/steps/:step_name/complete
338
+ def complete_step
339
+ api_complete_step
340
+ end
341
+
342
+ # POST /api/onboarding/steps/:step_name/skip
343
+ def skip_step
344
+ api_skip_step
345
+ end
346
+
347
+ # POST /api/onboarding/complete
348
+ def complete
349
+ api_complete_onboarding
350
+ end
351
+
352
+ # POST /api/onboarding/restart
353
+ def restart
354
+ api_restart_onboarding
355
+ end
356
+
357
+ # GET /api/onboarding/tooltips
358
+ def tooltips
359
+ api_tooltips_list
360
+ end
361
+
362
+ # POST /api/onboarding/tooltips/:tooltip_id/dismiss
363
+ def dismiss_tooltip
364
+ api_dismiss_tooltip
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ # One-shot data fixes for applications that installed RailsOnboarding on top
5
+ # of an existing users table.
6
+ #
7
+ # Users who signed up before the gem was installed have NULL onboarding
8
+ # columns, so the admin reports them as "Not Started" and - depending on the
9
+ # +onboarding_required_for+ strategy - they can be pushed back through a flow
10
+ # they have no business seeing. Backfilling marks them as already onboarded.
11
+ #
12
+ # Everything here writes with +update_all+ in batches: no callbacks, no
13
+ # validations, and deliberately no analytics events. Backfilled users never
14
+ # went through onboarding, so recording completion events for them would put
15
+ # fiction into the funnel metrics.
16
+ module Backfill
17
+ DEFAULT_BATCH_SIZE = 1_000
18
+
19
+ REQUIRED_COLUMNS = %w[
20
+ onboarding_completed
21
+ onboarding_completed_at
22
+ onboarding_current_step
23
+ ].freeze
24
+
25
+ Result = Struct.new(:matched, :updated, :dry_run, keyword_init: true) do
26
+ def dry_run?
27
+ dry_run
28
+ end
29
+ end
30
+
31
+ class << self
32
+ # Marks pre-existing users as having finished onboarding.
33
+ #
34
+ # Only users who have never engaged with onboarding are touched: not
35
+ # completed, not skipped, and sitting on no current step. Anyone
36
+ # mid-flow is left alone so a backfill can be re-run safely while the
37
+ # app is live.
38
+ #
39
+ # @param created_before [Time, Date, String, nil] restrict to users
40
+ # created before this moment. Users with a NULL +created_at+ are
41
+ # always included when a cutoff is given - a row with no creation
42
+ # timestamp predates any cutoff worth naming, and those rows are
43
+ # exactly the legacy ones this task exists for.
44
+ # @param completed_at [Time] timestamp used for users whose +created_at+
45
+ # is NULL. Users with a +created_at+ get their own signup time, which
46
+ # keeps completion-over-time charts from spiking on backfill day.
47
+ # @param batch_size [Integer] rows per UPDATE statement.
48
+ # @param dry_run [Boolean] count the affected rows without writing.
49
+ # @return [Result]
50
+ def mark_existing_users_onboarded(created_before: nil, completed_at: Time.current,
51
+ batch_size: DEFAULT_BATCH_SIZE, dry_run: false)
52
+ ensure_columns!
53
+
54
+ scope = pending_scope(created_before: created_before)
55
+ matched = scope.count
56
+ return Result.new(matched: matched, updated: 0, dry_run: true) if dry_run
57
+
58
+ updated = 0
59
+ scope.in_batches(of: batch_size) do |batch|
60
+ updated += batch.update_all(completion_assignment(completed_at))
61
+ end
62
+
63
+ Result.new(matched: matched, updated: updated, dry_run: false)
64
+ end
65
+
66
+ # Users the backfill would touch. Exposed so callers can inspect or
67
+ # further narrow the set before running.
68
+ def pending_scope(created_before: nil)
69
+ scope = user_class
70
+ .where(onboarding_current_step: nil)
71
+ .where(onboarding_completed: [ false, nil ])
72
+
73
+ scope = scope.where(onboarding_skipped: [ false, nil ]) if column?("onboarding_skipped")
74
+ return scope unless created_before
75
+
76
+ cutoff = normalize_time(created_before)
77
+ scope.where("#{quoted_table}.created_at < ? OR #{quoted_table}.created_at IS NULL", cutoff)
78
+ end
79
+
80
+ def user_class
81
+ RailsOnboarding.configuration.user_class_name.constantize
82
+ end
83
+
84
+ private
85
+
86
+ def completion_assignment(fallback_completed_at)
87
+ assignments = [
88
+ "onboarding_completed = ?",
89
+ "onboarding_completed_at = COALESCE(#{quoted_table}.created_at, ?)"
90
+ ]
91
+ values = [ true, normalize_time(fallback_completed_at) ]
92
+
93
+ # Columns added with `default: false` but no NOT NULL constraint can
94
+ # still hold NULL on rows written before the default landed, which
95
+ # makes `onboarding_skipped?` return nil-ish garbage in the admin.
96
+ if column?("onboarding_skipped")
97
+ assignments << "onboarding_skipped = COALESCE(#{quoted_table}.onboarding_skipped, ?)"
98
+ values << false
99
+ end
100
+
101
+ user_class.sanitize_sql_for_assignment([ assignments.join(", "), *values ])
102
+ end
103
+
104
+ def ensure_columns!
105
+ missing = REQUIRED_COLUMNS.reject { |name| column?(name) }
106
+ return if missing.empty?
107
+
108
+ raise ArgumentError,
109
+ "#{user_class.name} is missing onboarding columns: #{missing.join(', ')}. " \
110
+ "Run the RailsOnboarding install migrations first."
111
+ end
112
+
113
+ def column?(name)
114
+ user_class.column_names.include?(name)
115
+ end
116
+
117
+ def quoted_table
118
+ user_class.connection.quote_table_name(user_class.table_name)
119
+ end
120
+
121
+ def normalize_time(value)
122
+ case value
123
+ when String then Time.zone ? Time.zone.parse(value) : Time.parse(value)
124
+ when Date then value.to_time
125
+ else value
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end