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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ module Admin
5
+ # Admin flows controller
6
+ # Visual editor for creating and managing onboarding flows
7
+ class FlowsController < BaseController
8
+ before_action :set_flow, only: [ :show, :edit, :update, :destroy, :duplicate, :activate, :preview ]
9
+
10
+ def index
11
+ @pagy, @flows = paginate(flows_collection)
12
+ # Looked up independently of the page: the active flow is called out at the
13
+ # top of the screen whether or not it falls on the page being viewed.
14
+ @active_flow = RailsOnboarding::Flow.active.first
15
+ @templates = available_templates
16
+ end
17
+
18
+ def show
19
+ @steps = @flow.steps || []
20
+ @flow_stats = calculate_flow_stats(@flow)
21
+ end
22
+
23
+ def new
24
+ @flow = RailsOnboarding::Flow.new(default_flow_structure)
25
+ @available_icons = available_icons
26
+ end
27
+
28
+ def create
29
+ @flow = RailsOnboarding::Flow.new(flow_params)
30
+
31
+ if @flow.save
32
+ flash[:notice] = "Flow '#{@flow.name}' created successfully"
33
+ redirect_to admin_flows_path
34
+ else
35
+ flash.now[:alert] = "Failed to create flow: #{@flow.errors.full_messages.join(', ')}"
36
+ @available_icons = available_icons
37
+ render :new
38
+ end
39
+ end
40
+
41
+ def edit
42
+ @available_icons = available_icons
43
+ end
44
+
45
+ def update
46
+ if @flow.update(flow_params)
47
+ flash[:notice] = "Flow updated successfully"
48
+ redirect_to admin_flow_path(@flow)
49
+ else
50
+ flash.now[:alert] = "Failed to update flow: #{@flow.errors.full_messages.join(', ')}"
51
+ @available_icons = available_icons
52
+ render :edit
53
+ end
54
+ end
55
+
56
+ def destroy
57
+ if @flow.active?
58
+ flash[:alert] = "Failed to delete flow. Cannot delete active flow."
59
+ elsif @flow.destroy
60
+ flash[:notice] = "Flow deleted successfully"
61
+ else
62
+ flash[:alert] = "Failed to delete flow."
63
+ end
64
+ redirect_to admin_flows_path
65
+ end
66
+
67
+ def duplicate
68
+ new_flow = @flow.dup
69
+ new_flow.name = "#{@flow.name} (Copy)"
70
+ new_flow.active = false
71
+
72
+ if new_flow.save
73
+ flash[:notice] = "Flow duplicated successfully"
74
+ redirect_to admin_flow_path(new_flow)
75
+ else
76
+ flash[:alert] = "Failed to duplicate flow"
77
+ redirect_to admin_flows_path
78
+ end
79
+ end
80
+
81
+ def activate
82
+ RailsOnboarding::Flow.activate!(@flow)
83
+ RailsOnboarding.configuration.clear_cache!
84
+ flash[:notice] = "Flow '#{@flow.name}' is now active"
85
+ redirect_to admin_flows_path
86
+ rescue StandardError => e
87
+ logger.error "Error activating flow: #{e.message}"
88
+ flash[:alert] = "Failed to activate flow"
89
+ redirect_to admin_flows_path
90
+ end
91
+
92
+ def preview
93
+ @steps = @flow.steps || []
94
+ render layout: "rails_onboarding/application"
95
+ end
96
+
97
+ private
98
+
99
+ def set_flow
100
+ @flow = RailsOnboarding::Flow.find_by(id: params[:id])
101
+ unless @flow
102
+ flash[:alert] = "Flow not found"
103
+ redirect_to admin_flows_path
104
+ end
105
+ end
106
+
107
+ def flows_collection
108
+ RailsOnboarding::Flow.seed_default! if RailsOnboarding::Flow.none?
109
+ RailsOnboarding::Flow.order(created_at: :asc)
110
+ end
111
+
112
+ def default_flow_structure
113
+ {
114
+ name: "",
115
+ description: "",
116
+ steps: [
117
+ { name: "welcome", title: "Welcome", icon: "👋", skippable: true, order: 0 }
118
+ ]
119
+ }
120
+ end
121
+
122
+ def flow_params
123
+ permitted = params.require(:flow).permit(
124
+ :name, :description,
125
+ steps: [ :name, :title, :icon, :description, :skippable, :order ]
126
+ )
127
+
128
+ if permitted[:steps]
129
+ permitted[:steps] = permitted[:steps].map { |step| step.to_h.symbolize_keys }
130
+ .sort_by { |step| step[:order].to_i }
131
+ end
132
+
133
+ permitted
134
+ end
135
+
136
+ def calculate_flow_stats(flow)
137
+ return {} unless defined?(RailsOnboarding::AnalyticsEvent)
138
+
139
+ steps = flow.steps || []
140
+ stats = {}
141
+
142
+ steps.each do |step|
143
+ step_name = step[:name].to_s
144
+ stats[step_name] = {
145
+ started: RailsOnboarding::AnalyticsEvent.where(event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED)
146
+ .select { |e| e.properties.to_h["step_name"].to_s == step_name }.count,
147
+ completed: RailsOnboarding::AnalyticsEvent.where(event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_COMPLETED)
148
+ .select { |e| e.properties.to_h["step_name"].to_s == step_name }.count
149
+ }
150
+ end
151
+
152
+ stats
153
+ rescue StandardError => e
154
+ logger.error "Error calculating flow stats: #{e.message}"
155
+ {}
156
+ end
157
+
158
+ def available_templates
159
+ return [] unless defined?(RailsOnboarding::Templates)
160
+
161
+ [
162
+ { id: "saas", name: "SaaS Application", description: "Standard SaaS onboarding flow" },
163
+ { id: "ecommerce", name: "E-commerce", description: "Onboarding for online stores" },
164
+ { id: "marketplace", name: "Marketplace", description: "Two-sided marketplace flow" },
165
+ { id: "community", name: "Community", description: "Social/community platform" },
166
+ { id: "education", name: "Education", description: "Learning platform flow" }
167
+ ]
168
+ end
169
+
170
+ def available_icons
171
+ [ "👋", "🎉", "👤", "📝", "🚀", "🔍", "⚙️", "💡", "📊", "🎯", "✨", "🏆", "📱", "💬", "🔔", "📧", "🎨", "🔒" ]
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOnboarding
4
+ module Admin
5
+ # Admin users controller
6
+ # Manage and view user onboarding progress
7
+ class UsersController < BaseController
8
+ MYSQL_ADAPTERS = %w[mysql2 trilogy mysql].freeze
9
+
10
+ # The cast target for turning an integer id into something LIKE can match
11
+ # is not portable. MySQL rejects `CAST(x AS TEXT)` outright as a syntax
12
+ # error, and PostgreSQL accepts `CAST(x AS CHAR)` but reads it as
13
+ # character(1), silently truncating the id to its first digit. SQLite
14
+ # takes either. Defined on the class rather than as an instance method so
15
+ # Rails doesn't expose it as a controller action.
16
+ def self.id_cast_type_for(adapter_name)
17
+ MYSQL_ADAPTERS.include?(adapter_name.to_s.downcase) ? "CHAR" : "TEXT"
18
+ end
19
+
20
+ def index
21
+ @pagy, @users = paginate(filtered_users)
22
+ @stats = calculate_stats
23
+ end
24
+
25
+ def show
26
+ @user = user_class.find(params[:id])
27
+ load_user_analytics
28
+ end
29
+
30
+ def reset_onboarding
31
+ @user = user_class.find(params[:id])
32
+
33
+ if @user.reset_onboarding!
34
+ flash[:notice] = "Onboarding reset for user #{@user.id}"
35
+ redirect_to admin_user_path(@user)
36
+ else
37
+ flash[:alert] = "Failed to reset onboarding"
38
+ redirect_to admin_user_path(@user)
39
+ end
40
+ rescue StandardError => e
41
+ flash[:alert] = "Error resetting onboarding: #{e.message}"
42
+ redirect_to admin_users_path
43
+ end
44
+
45
+ def complete_onboarding
46
+ @user = user_class.find(params[:id])
47
+
48
+ if @user.complete_onboarding!
49
+ flash[:notice] = "Onboarding completed for user #{@user.id}"
50
+ redirect_to admin_user_path(@user)
51
+ else
52
+ flash[:alert] = "Failed to complete onboarding"
53
+ redirect_to admin_user_path(@user)
54
+ end
55
+ rescue StandardError => e
56
+ flash[:alert] = "Error completing onboarding: #{e.message}"
57
+ redirect_to admin_users_path
58
+ end
59
+
60
+ def restart_onboarding
61
+ @user = user_class.find(params[:id])
62
+
63
+ @user.restart_onboarding!
64
+ flash[:notice] = "Onboarding restarted for user #{@user.id}"
65
+ redirect_to admin_user_path(@user)
66
+ rescue StandardError => e
67
+ flash[:alert] = "Error restarting onboarding: #{e.message}"
68
+ redirect_to admin_users_path
69
+ end
70
+
71
+ def bulk_action
72
+ action = params[:bulk_action]
73
+ user_ids = params[:user_ids] || []
74
+
75
+ case action
76
+ when "reset_onboarding"
77
+ bulk_reset_onboarding(user_ids)
78
+ when "complete_onboarding"
79
+ bulk_complete_onboarding(user_ids)
80
+ else
81
+ flash[:alert] = "Invalid bulk action"
82
+ end
83
+
84
+ redirect_to admin_users_path
85
+ end
86
+
87
+ # Exports every user matching the current filters, not just the page being
88
+ # viewed - the button sits under the filter form, so "export what I'm
89
+ # looking at" is the expectation. Deliberately unpaginated.
90
+ # CSV is the only representation, so this deliberately doesn't respond_to:
91
+ # a bare /admin/users/export would otherwise raise UnknownFormat, which the
92
+ # admin error handler turns into a dashboard redirect with an alert.
93
+ def export
94
+ send_data generate_users_csv,
95
+ filename: "onboarding_users_#{Date.current}.csv",
96
+ type: "text/csv"
97
+ end
98
+
99
+ private
100
+
101
+ def generate_users_csv
102
+ require "csv"
103
+
104
+ has_email = user_class.column_names.include?("email")
105
+
106
+ CSV.generate do |csv|
107
+ csv << csv_headers(has_email)
108
+ # Iterated in the admin's chosen sort order, so no find_each here: it
109
+ # would override the ORDER BY with a primary-key scan and silently
110
+ # discard the sort the export was requested under.
111
+ filtered_users.each { |user| csv << csv_row(user, has_email) }
112
+ end
113
+ end
114
+
115
+ def csv_headers(has_email)
116
+ headers = [ "ID" ]
117
+ headers << "Email" if has_email
118
+ headers + [ "Status", "Current Step", "Progress (%)", "Completed At", "Created At", "Last Activity" ]
119
+ end
120
+
121
+ def csv_row(user, has_email)
122
+ row = [ user.id ]
123
+ row << user.email if has_email
124
+ row + [
125
+ onboarding_status_label(user),
126
+ user.onboarding_current_step,
127
+ user.respond_to?(:onboarding_progress_percentage) ? user.onboarding_progress_percentage : nil,
128
+ user.onboarding_completed_at&.iso8601,
129
+ user.created_at&.iso8601,
130
+ user.updated_at&.iso8601
131
+ ]
132
+ end
133
+
134
+ # Mirrors the badge shown in the index table, including its precedence:
135
+ # completed wins over skipped, which wins over in-progress.
136
+ def onboarding_status_label(user)
137
+ if user.onboarding_completed
138
+ "Completed"
139
+ elsif user.onboarding_skipped
140
+ "Skipped"
141
+ elsif user.onboarding_current_step
142
+ "In Progress"
143
+ else
144
+ "Not Started"
145
+ end
146
+ end
147
+
148
+ # The search box covers both email and ID. Each half contributes a scope
149
+ # only when it could actually match; if neither can, the result is none
150
+ # rather than an empty WHERE that would return every user.
151
+ #
152
+ # A host app can replace the whole thing via
153
+ # `config.admin_user_search` - the built-in search is SQL, so it can't
154
+ # see through an encrypted email column, and only the host app knows
155
+ # what it's willing to trade to search one.
156
+ def apply_search(users, term)
157
+ custom = RailsOnboarding.configuration.admin_user_search
158
+ return custom.call(users, term) if custom.respond_to?(:call)
159
+
160
+ scopes = [ email_search_scope(users, term), id_search_scope(users, term) ].compact
161
+ return users.none if scopes.empty?
162
+
163
+ scopes.reduce { |combined, scope| combined.or(scope) }
164
+ end
165
+
166
+ # Rails' `encrypts` stores ciphertext in the column, so a LIKE against a
167
+ # substring of the plaintext can't match - and worse, it happily matches
168
+ # ciphertext that happens to contain the term, so an app with an
169
+ # encrypted email column sees confident nonsense rather than nothing.
170
+ # Deterministic encryption still supports exact equality (same plaintext
171
+ # always encrypts to the same ciphertext), so fall back to that. With
172
+ # non-deterministic encryption there is nothing to search on at all.
173
+ def email_search_scope(users, term)
174
+ return nil unless user_class.column_names.include?("email")
175
+
176
+ case email_encryption
177
+ when :none
178
+ users.where("#{user_class.table_name}.email LIKE ?", "%#{term}%")
179
+ when :deterministic
180
+ users.where(email: term)
181
+ end
182
+ end
183
+
184
+ def email_encryption
185
+ return :none unless user_class.respond_to?(:encrypted_attributes)
186
+ return :none unless user_class.encrypted_attributes&.include?(:email)
187
+
188
+ type = user_class.type_for_attribute("email")
189
+ type.respond_to?(:deterministic?) && type.deterministic? ? :deterministic : :non_deterministic
190
+ end
191
+
192
+ # An id renders as digits, so a non-numeric term can never be a substring
193
+ # of one. Dropping the clause in that case keeps the common email search
194
+ # off a full-table cast scan rather than merely being tidy.
195
+ def id_search_scope(users, term)
196
+ return nil unless term.match?(/\A\d+\z/)
197
+
198
+ cast_type = self.class.id_cast_type_for(user_class.connection.adapter_name)
199
+ users.where("CAST(#{user_class.table_name}.id AS #{cast_type}) LIKE ?", "%#{term}%")
200
+ end
201
+
202
+ def filtered_users
203
+ users = user_class.all
204
+
205
+ # Filter by onboarding status
206
+ if params[:status].present?
207
+ users = case params[:status]
208
+ when "completed"
209
+ users.where(onboarding_completed: true)
210
+ when "in_progress"
211
+ users.where(onboarding_completed: false).where.not(onboarding_current_step: nil)
212
+ when "not_started"
213
+ users.where(onboarding_current_step: nil, onboarding_completed: false)
214
+ when "skipped"
215
+ users.where(onboarding_skipped: true)
216
+ else
217
+ users
218
+ end
219
+ end
220
+
221
+ # Filter by current step
222
+ if params[:step].present?
223
+ users = users.where(onboarding_current_step: params[:step])
224
+ end
225
+
226
+ # Search by email or ID
227
+ users = apply_search(users, params[:search]) if params[:search].present?
228
+
229
+ # Sort - sanitize column and direction to prevent SQL injection
230
+ allowed_sort_columns = %w[email created_at updated_at onboarding_current_step onboarding_completed_at].freeze
231
+ allowed_directions = %w[asc desc].freeze
232
+
233
+ sort_column = params[:sort] || "created_at"
234
+ sort_direction = params[:direction] || "desc"
235
+
236
+ # Sanitize inputs
237
+ sort_column = allowed_sort_columns.include?(sort_column) ? sort_column : "created_at"
238
+ sort_direction = allowed_directions.include?(sort_direction.downcase) ? sort_direction.downcase : "desc"
239
+
240
+ users = users.order("#{user_class.table_name}.#{sort_column} #{sort_direction}")
241
+
242
+ users
243
+ end
244
+
245
+ def calculate_stats
246
+ {
247
+ total: user_class.count,
248
+ completed: user_class.where(onboarding_completed: true).count,
249
+ in_progress: user_class.where(onboarding_completed: false).where.not(onboarding_current_step: nil).count,
250
+ not_started: user_class.where(onboarding_current_step: nil, onboarding_completed: false).count,
251
+ skipped: user_class.where(onboarding_skipped: true).count
252
+ }
253
+ end
254
+
255
+ def load_user_analytics
256
+ return unless defined?(RailsOnboarding::AnalyticsEvent)
257
+
258
+ @user_events = RailsOnboarding::AnalyticsEvent
259
+ .where(user_id: @user.id)
260
+ .order(created_at: :desc)
261
+ .limit(50)
262
+
263
+ @user_milestones = if defined?(RailsOnboarding::MilestoneAchievement)
264
+ RailsOnboarding::MilestoneAchievement
265
+ .where(user_id: @user.id)
266
+ .includes(:milestone)
267
+ .order(achieved_at: :desc)
268
+ else
269
+ []
270
+ end
271
+
272
+ @user_timeline = build_user_timeline
273
+ end
274
+
275
+ def build_user_timeline
276
+ timeline = []
277
+
278
+ if @user.created_at
279
+ timeline << {
280
+ type: "user_created",
281
+ timestamp: @user.created_at,
282
+ description: "User account created"
283
+ }
284
+ end
285
+
286
+ if defined?(RailsOnboarding::AnalyticsEvent)
287
+ @user_events.each do |event|
288
+ timeline << {
289
+ type: event.event_type,
290
+ timestamp: event.created_at,
291
+ description: format_event_description(event),
292
+ metadata: event.properties.to_h
293
+ }
294
+ end
295
+ end
296
+
297
+ if @user_milestones.any?
298
+ @user_milestones.each do |achievement|
299
+ timeline << {
300
+ type: "milestone_achieved",
301
+ timestamp: achievement.achieved_at,
302
+ description: "Achieved milestone: #{achievement.milestone.title}",
303
+ metadata: { points: achievement.milestone.points }
304
+ }
305
+ end
306
+ end
307
+
308
+ # A nil timestamp would blow up the sort, and the timeline renders every
309
+ # entry as a date - so drop entries we can't place in time at all.
310
+ timeline.reject { |item| item[:timestamp].nil? }
311
+ .sort_by { |item| item[:timestamp] }
312
+ .reverse
313
+ end
314
+
315
+ def format_event_description(event)
316
+ props = event.properties.to_h
317
+ case event.event_type
318
+ when RailsOnboarding::AnalyticsEvent::ONBOARDING_STARTED
319
+ "Started onboarding"
320
+ when RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED
321
+ "Started step: #{props['step_name']}"
322
+ when RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_COMPLETED
323
+ "Completed step: #{props['step_name']}"
324
+ when RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_SKIPPED
325
+ "Skipped step: #{props['step_name']}"
326
+ when RailsOnboarding::AnalyticsEvent::ONBOARDING_COMPLETED
327
+ "Completed onboarding"
328
+ when RailsOnboarding::AnalyticsEvent::ONBOARDING_SKIPPED
329
+ "Skipped onboarding"
330
+ when RailsOnboarding::AnalyticsEvent::TOOLTIP_SHOWN
331
+ "Tooltip shown: #{props['tooltip_feature']}"
332
+ when RailsOnboarding::AnalyticsEvent::TOOLTIP_DISMISSED
333
+ "Tooltip dismissed: #{props['tooltip_feature']}"
334
+ else
335
+ event.event_type.humanize
336
+ end
337
+ end
338
+
339
+ def bulk_reset_onboarding(user_ids)
340
+ count = 0
341
+ user_ids.each do |user_id|
342
+ user = user_class.find_by(id: user_id)
343
+ count += 1 if user&.reset_onboarding!
344
+ end
345
+ flash[:notice] = "Reset onboarding for #{count} user(s)"
346
+ rescue StandardError => e
347
+ flash[:alert] = "Error in bulk reset: #{e.message}"
348
+ end
349
+
350
+ def bulk_complete_onboarding(user_ids)
351
+ count = 0
352
+ user_ids.each do |user_id|
353
+ user = user_class.find_by(id: user_id)
354
+ count += 1 if user&.complete_onboarding!
355
+ end
356
+ flash[:notice] = "Completed onboarding for #{count} user(s)"
357
+ rescue StandardError => e
358
+ flash[:alert] = "Error in bulk complete: #{e.message}"
359
+ end
360
+
361
+ def user_class
362
+ @user_class ||= RailsOnboarding.configuration.user_class_name.constantize
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,9 @@
1
+ module RailsOnboarding
2
+ class ApplicationController < ApplicationController
3
+ # Inherits from the host app's ApplicationController
4
+ # This gives us access to:
5
+ # - current_user method
6
+ # - authentication filters
7
+ # - any other helpers defined in the host app
8
+ end
9
+ end
@@ -0,0 +1,126 @@
1
+ module RailsOnboarding
2
+ class MilestonesController < ApplicationController
3
+ include RailsOnboarding::RateLimitable
4
+
5
+ before_action :authenticate_user!
6
+ before_action :check_milestones_enabled
7
+
8
+ def index
9
+ achieved_milestones = current_user.achieved_milestones.map do |key|
10
+ RailsOnboarding.configuration.milestone_by_key(key)
11
+ end.compact
12
+
13
+ available_milestones = current_user.milestones_available
14
+ total_points = current_user.total_milestone_points
15
+
16
+ respond_to do |format|
17
+ format.html do
18
+ @achieved_milestones = achieved_milestones
19
+ @available_milestones = available_milestones
20
+ @total_points = total_points
21
+ end
22
+ format.json do
23
+ render json: (achieved_milestones + available_milestones)
24
+ end
25
+ end
26
+ end
27
+
28
+ def show
29
+ milestone_key = params[:id]
30
+ @milestone = RailsOnboarding.configuration.milestone_by_key(milestone_key)
31
+
32
+ unless @milestone
33
+ redirect_to milestones_path, alert: "Milestone not found"
34
+ return
35
+ end
36
+
37
+ @achieved = current_user.milestone_achieved?(milestone_key)
38
+ end
39
+
40
+ def achieve
41
+ milestone = MilestoneService.claim_if_eligible(current_user, params[:milestone_key])
42
+
43
+ if milestone
44
+ render json: {
45
+ success: true,
46
+ milestone: milestone,
47
+ total_points: current_user.total_milestone_points,
48
+ celebration: true
49
+ }
50
+ else
51
+ render json: {
52
+ success: false,
53
+ error: "Milestone could not be achieved"
54
+ }, status: :forbidden
55
+ end
56
+ end
57
+
58
+ def recent
59
+ @recent_milestones = current_user.recent_milestones(limit: params[:limit]&.to_i || 5)
60
+ render json: @recent_milestones
61
+ end
62
+
63
+ def progress
64
+ render json: {
65
+ points: current_user.milestone_points || 0,
66
+ achieved: current_user.milestones_achieved.map { |m| m["key"] || m[:key] }.compact
67
+ }
68
+ end
69
+
70
+ def check
71
+ milestone_id = params[:id]
72
+ achieved = current_user.milestone_achieved?(milestone_id)
73
+
74
+ render json: { achieved: achieved }
75
+ end
76
+
77
+ def trigger
78
+ milestone_id = params[:milestone_id]
79
+
80
+ # Check if milestone is already achieved
81
+ if current_user.milestone_achieved?(milestone_id)
82
+ render json: {
83
+ success: false,
84
+ message: "Milestone already achieved"
85
+ }
86
+ return
87
+ end
88
+
89
+ # Award the milestone only if the user actually qualifies for it
90
+ milestone = MilestoneService.claim_if_eligible(current_user, milestone_id)
91
+
92
+ if milestone
93
+ render json: {
94
+ success: true,
95
+ celebration: true,
96
+ points_awarded: milestone[:points] || 0,
97
+ total_points: current_user.milestone_points || 0
98
+ }
99
+ else
100
+ render json: {
101
+ success: false,
102
+ error: "You are not eligible for this milestone yet"
103
+ }, status: :forbidden
104
+ end
105
+ end
106
+
107
+ def available
108
+ available_milestones = current_user.milestones_available || []
109
+ render json: available_milestones
110
+ end
111
+
112
+ private
113
+
114
+ def authenticate_user!
115
+ unless respond_to?(:current_user) && current_user
116
+ redirect_to main_app.root_path, alert: "Please log in to view milestones"
117
+ end
118
+ end
119
+
120
+ def check_milestones_enabled
121
+ unless RailsOnboarding.configuration.enable_milestones
122
+ redirect_to main_app.root_path, alert: "Milestones are not enabled"
123
+ end
124
+ end
125
+ end
126
+ end