activity_notification 2.4.1 → 2.5.0

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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/app/jobs/activity_notification/cascading_notification_job.rb +123 -0
  4. data/docs/Functions.md +197 -1
  5. data/lib/activity_notification/apis/cascading_notification_api.rb +208 -0
  6. data/lib/activity_notification/apis/notification_api.rb +3 -0
  7. data/lib/activity_notification/config.rb +10 -0
  8. data/lib/activity_notification/mailers/helpers.rb +27 -1
  9. data/lib/activity_notification/version.rb +1 -1
  10. data/lib/generators/templates/activity_notification.rb +8 -0
  11. metadata +5 -441
  12. data/.codeclimate.yml +0 -33
  13. data/.coveralls.yml +0 -1
  14. data/.github/ISSUE_TEMPLATE/bug_report.md +0 -22
  15. data/.github/ISSUE_TEMPLATE/feature_request.md +0 -17
  16. data/.github/pull_request_template.md +0 -13
  17. data/.github/workflows/build.yml +0 -100
  18. data/.gitignore +0 -74
  19. data/.rspec +0 -3
  20. data/.rubocop.yml +0 -1157
  21. data/.yardopts +0 -6
  22. data/CHANGELOG.md +0 -452
  23. data/Gemfile +0 -31
  24. data/Procfile +0 -2
  25. data/Rakefile +0 -28
  26. data/activity_notification.gemspec +0 -44
  27. data/ai-curated-specs/issues/172/design.md +0 -220
  28. data/ai-curated-specs/issues/172/tasks.md +0 -326
  29. data/ai-curated-specs/issues/188/design.md +0 -227
  30. data/ai-curated-specs/issues/188/requirements.md +0 -78
  31. data/ai-curated-specs/issues/188/tasks.md +0 -203
  32. data/ai-curated-specs/issues/188/upstream-contributions.md +0 -592
  33. data/ai-curated-specs/issues/50/design.md +0 -235
  34. data/ai-curated-specs/issues/50/requirements.md +0 -49
  35. data/ai-curated-specs/issues/50/tasks.md +0 -232
  36. data/bin/_dynamodblocal +0 -4
  37. data/bin/bundle_update.sh +0 -7
  38. data/bin/deploy_on_heroku.sh +0 -16
  39. data/bin/install_dynamodblocal.sh +0 -5
  40. data/bin/start_dynamodblocal.sh +0 -47
  41. data/bin/stop_dynamodblocal.sh +0 -34
  42. data/gemfiles/Gemfile.rails-5.0 +0 -25
  43. data/gemfiles/Gemfile.rails-5.1 +0 -25
  44. data/gemfiles/Gemfile.rails-5.2 +0 -24
  45. data/gemfiles/Gemfile.rails-6.0 +0 -23
  46. data/gemfiles/Gemfile.rails-6.1 +0 -22
  47. data/gemfiles/Gemfile.rails-7.0 +0 -25
  48. data/gemfiles/Gemfile.rails-7.1 +0 -23
  49. data/gemfiles/Gemfile.rails-7.2 +0 -23
  50. data/gemfiles/Gemfile.rails-8.0 +0 -24
  51. data/package.json +0 -8
  52. data/spec/channels/notification_api_channel_shared_examples.rb +0 -59
  53. data/spec/channels/notification_api_channel_spec.rb +0 -49
  54. data/spec/channels/notification_api_with_devise_channel_spec.rb +0 -76
  55. data/spec/channels/notification_channel_shared_examples.rb +0 -59
  56. data/spec/channels/notification_channel_spec.rb +0 -48
  57. data/spec/channels/notification_with_devise_channel_spec.rb +0 -97
  58. data/spec/concerns/apis/notification_api_spec.rb +0 -1627
  59. data/spec/concerns/apis/subscription_api_spec.rb +0 -474
  60. data/spec/concerns/common_spec.rb +0 -213
  61. data/spec/concerns/models/group_spec.rb +0 -61
  62. data/spec/concerns/models/notifiable_spec.rb +0 -782
  63. data/spec/concerns/models/notifier_spec.rb +0 -71
  64. data/spec/concerns/models/subscriber_spec.rb +0 -800
  65. data/spec/concerns/models/target_spec.rb +0 -1285
  66. data/spec/concerns/renderable_spec.rb +0 -129
  67. data/spec/config_spec.rb +0 -85
  68. data/spec/controllers/common_controller_spec.rb +0 -25
  69. data/spec/controllers/controller_spec_utility.rb +0 -100
  70. data/spec/controllers/dummy_common_controller.rb +0 -5
  71. data/spec/controllers/notifications_api_controller_shared_examples.rb +0 -619
  72. data/spec/controllers/notifications_api_controller_spec.rb +0 -19
  73. data/spec/controllers/notifications_api_with_devise_controller_spec.rb +0 -60
  74. data/spec/controllers/notifications_controller_shared_examples.rb +0 -743
  75. data/spec/controllers/notifications_controller_spec.rb +0 -11
  76. data/spec/controllers/notifications_with_devise_controller_spec.rb +0 -97
  77. data/spec/controllers/subscriptions_api_controller_shared_examples.rb +0 -750
  78. data/spec/controllers/subscriptions_api_controller_spec.rb +0 -19
  79. data/spec/controllers/subscriptions_api_with_devise_controller_spec.rb +0 -60
  80. data/spec/controllers/subscriptions_controller_shared_examples.rb +0 -946
  81. data/spec/controllers/subscriptions_controller_spec.rb +0 -11
  82. data/spec/controllers/subscriptions_with_devise_controller_spec.rb +0 -97
  83. data/spec/factories/admins.rb +0 -5
  84. data/spec/factories/articles.rb +0 -5
  85. data/spec/factories/comments.rb +0 -6
  86. data/spec/factories/dummy/dummy_group.rb +0 -4
  87. data/spec/factories/dummy/dummy_notifiable.rb +0 -4
  88. data/spec/factories/dummy/dummy_notifier.rb +0 -4
  89. data/spec/factories/dummy/dummy_subscriber.rb +0 -4
  90. data/spec/factories/dummy/dummy_target.rb +0 -4
  91. data/spec/factories/notifications.rb +0 -7
  92. data/spec/factories/subscriptions.rb +0 -8
  93. data/spec/factories/users.rb +0 -11
  94. data/spec/generators/controllers_generator_spec.rb +0 -85
  95. data/spec/generators/install_generator_spec.rb +0 -43
  96. data/spec/generators/migration/migration_generator_spec.rb +0 -80
  97. data/spec/generators/models_generator_spec.rb +0 -96
  98. data/spec/generators/views_generator_spec.rb +0 -195
  99. data/spec/helpers/polymorphic_helpers_spec.rb +0 -89
  100. data/spec/helpers/view_helpers_spec.rb +0 -547
  101. data/spec/jobs/notification_resilience_job_spec.rb +0 -167
  102. data/spec/jobs/notify_all_job_spec.rb +0 -23
  103. data/spec/jobs/notify_job_spec.rb +0 -23
  104. data/spec/jobs/notify_to_job_spec.rb +0 -23
  105. data/spec/mailers/mailer_spec.rb +0 -214
  106. data/spec/mailers/notification_resilience_spec.rb +0 -263
  107. data/spec/models/dummy/dummy_group_spec.rb +0 -10
  108. data/spec/models/dummy/dummy_notifiable_spec.rb +0 -10
  109. data/spec/models/dummy/dummy_notifier_spec.rb +0 -10
  110. data/spec/models/dummy/dummy_subscriber_spec.rb +0 -8
  111. data/spec/models/dummy/dummy_target_spec.rb +0 -10
  112. data/spec/models/notification_spec.rb +0 -472
  113. data/spec/models/subscription_spec.rb +0 -215
  114. data/spec/optional_targets/action_cable_api_channel_spec.rb +0 -34
  115. data/spec/optional_targets/action_cable_channel_spec.rb +0 -41
  116. data/spec/optional_targets/amazon_sns_spec.rb +0 -47
  117. data/spec/optional_targets/base_spec.rb +0 -45
  118. data/spec/optional_targets/slack_spec.rb +0 -44
  119. data/spec/orm/dynamoid_spec.rb +0 -115
  120. data/spec/rails_app/Rakefile +0 -15
  121. data/spec/rails_app/app/assets/config/manifest.js +0 -3
  122. data/spec/rails_app/app/assets/images/.keep +0 -0
  123. data/spec/rails_app/app/assets/javascripts/application.js +0 -3
  124. data/spec/rails_app/app/assets/javascripts/cable.js +0 -12
  125. data/spec/rails_app/app/assets/stylesheets/application.css +0 -15
  126. data/spec/rails_app/app/assets/stylesheets/reset.css +0 -85
  127. data/spec/rails_app/app/assets/stylesheets/style.css +0 -244
  128. data/spec/rails_app/app/controllers/admins_controller.rb +0 -21
  129. data/spec/rails_app/app/controllers/application_controller.rb +0 -5
  130. data/spec/rails_app/app/controllers/articles_controller.rb +0 -67
  131. data/spec/rails_app/app/controllers/comments_controller.rb +0 -36
  132. data/spec/rails_app/app/controllers/concerns/.keep +0 -0
  133. data/spec/rails_app/app/controllers/spa_controller.rb +0 -7
  134. data/spec/rails_app/app/controllers/users/notifications_controller.rb +0 -2
  135. data/spec/rails_app/app/controllers/users/notifications_with_devise_controller.rb +0 -2
  136. data/spec/rails_app/app/controllers/users/subscriptions_controller.rb +0 -2
  137. data/spec/rails_app/app/controllers/users/subscriptions_with_devise_controller.rb +0 -2
  138. data/spec/rails_app/app/controllers/users_controller.rb +0 -26
  139. data/spec/rails_app/app/helpers/application_helper.rb +0 -2
  140. data/spec/rails_app/app/helpers/devise_helper.rb +0 -2
  141. data/spec/rails_app/app/javascript/App.vue +0 -40
  142. data/spec/rails_app/app/javascript/components/DeviseTokenAuth.vue +0 -82
  143. data/spec/rails_app/app/javascript/components/Top.vue +0 -98
  144. data/spec/rails_app/app/javascript/components/notifications/Index.vue +0 -200
  145. data/spec/rails_app/app/javascript/components/notifications/Notification.vue +0 -133
  146. data/spec/rails_app/app/javascript/components/notifications/NotificationContent.vue +0 -122
  147. data/spec/rails_app/app/javascript/components/subscriptions/Index.vue +0 -279
  148. data/spec/rails_app/app/javascript/components/subscriptions/NewSubscription.vue +0 -112
  149. data/spec/rails_app/app/javascript/components/subscriptions/NotificationKey.vue +0 -141
  150. data/spec/rails_app/app/javascript/components/subscriptions/Subscription.vue +0 -226
  151. data/spec/rails_app/app/javascript/config/development.js +0 -5
  152. data/spec/rails_app/app/javascript/config/environment.js +0 -7
  153. data/spec/rails_app/app/javascript/config/production.js +0 -5
  154. data/spec/rails_app/app/javascript/config/test.js +0 -5
  155. data/spec/rails_app/app/javascript/packs/application.js +0 -18
  156. data/spec/rails_app/app/javascript/packs/spa.js +0 -14
  157. data/spec/rails_app/app/javascript/router/index.js +0 -73
  158. data/spec/rails_app/app/javascript/store/index.js +0 -37
  159. data/spec/rails_app/app/mailers/.keep +0 -0
  160. data/spec/rails_app/app/mailers/custom_notification_mailer.rb +0 -5
  161. data/spec/rails_app/app/models/admin.rb +0 -35
  162. data/spec/rails_app/app/models/article.rb +0 -54
  163. data/spec/rails_app/app/models/comment.rb +0 -81
  164. data/spec/rails_app/app/models/dummy/dummy_base.rb +0 -11
  165. data/spec/rails_app/app/models/dummy/dummy_group.rb +0 -23
  166. data/spec/rails_app/app/models/dummy/dummy_notifiable.rb +0 -15
  167. data/spec/rails_app/app/models/dummy/dummy_notifiable_target.rb +0 -27
  168. data/spec/rails_app/app/models/dummy/dummy_notifier.rb +0 -15
  169. data/spec/rails_app/app/models/dummy/dummy_subscriber.rb +0 -14
  170. data/spec/rails_app/app/models/dummy/dummy_target.rb +0 -16
  171. data/spec/rails_app/app/models/user.rb +0 -73
  172. data/spec/rails_app/app/views/activity_notification/mailer/dummy_subscribers/test_key.text.erb +0 -1
  173. data/spec/rails_app/app/views/activity_notification/notifications/default/article/_update.html.erb +0 -146
  174. data/spec/rails_app/app/views/activity_notification/notifications/default/custom/_path_test.html.erb +0 -1
  175. data/spec/rails_app/app/views/activity_notification/notifications/default/custom/_test.html.erb +0 -1
  176. data/spec/rails_app/app/views/activity_notification/notifications/users/_custom_index.html.erb +0 -1
  177. data/spec/rails_app/app/views/activity_notification/notifications/users/custom/_test.html.erb +0 -1
  178. data/spec/rails_app/app/views/activity_notification/notifications/users/overridden/custom/_test.html.erb +0 -1
  179. data/spec/rails_app/app/views/activity_notification/optional_targets/admins/amazon_sns/comment/_default.text.erb +0 -10
  180. data/spec/rails_app/app/views/articles/_form.html.erb +0 -24
  181. data/spec/rails_app/app/views/articles/edit.html.erb +0 -8
  182. data/spec/rails_app/app/views/articles/index.html.erb +0 -113
  183. data/spec/rails_app/app/views/articles/new.html.erb +0 -7
  184. data/spec/rails_app/app/views/articles/show.html.erb +0 -49
  185. data/spec/rails_app/app/views/layouts/_header.html.erb +0 -46
  186. data/spec/rails_app/app/views/layouts/application.html.erb +0 -15
  187. data/spec/rails_app/app/views/spa/index.html.erb +0 -2
  188. data/spec/rails_app/babel.config.js +0 -72
  189. data/spec/rails_app/bin/bundle +0 -3
  190. data/spec/rails_app/bin/rails +0 -4
  191. data/spec/rails_app/bin/rake +0 -4
  192. data/spec/rails_app/bin/setup +0 -29
  193. data/spec/rails_app/bin/webpack +0 -18
  194. data/spec/rails_app/bin/webpack-dev-server +0 -18
  195. data/spec/rails_app/config/application.rb +0 -54
  196. data/spec/rails_app/config/boot.rb +0 -5
  197. data/spec/rails_app/config/cable.yml +0 -8
  198. data/spec/rails_app/config/database.yml +0 -36
  199. data/spec/rails_app/config/dynamoid.rb +0 -13
  200. data/spec/rails_app/config/environment.rb +0 -26
  201. data/spec/rails_app/config/environments/development.rb +0 -60
  202. data/spec/rails_app/config/environments/production.rb +0 -85
  203. data/spec/rails_app/config/environments/test.rb +0 -53
  204. data/spec/rails_app/config/initializers/activity_notification.rb +0 -104
  205. data/spec/rails_app/config/initializers/assets.rb +0 -11
  206. data/spec/rails_app/config/initializers/backtrace_silencers.rb +0 -7
  207. data/spec/rails_app/config/initializers/cookies_serializer.rb +0 -3
  208. data/spec/rails_app/config/initializers/copy_it.aws.rb.template +0 -6
  209. data/spec/rails_app/config/initializers/devise.rb +0 -278
  210. data/spec/rails_app/config/initializers/devise_token_auth.rb +0 -55
  211. data/spec/rails_app/config/initializers/filter_parameter_logging.rb +0 -4
  212. data/spec/rails_app/config/initializers/inflections.rb +0 -16
  213. data/spec/rails_app/config/initializers/mime_types.rb +0 -4
  214. data/spec/rails_app/config/initializers/mysql.rb +0 -9
  215. data/spec/rails_app/config/initializers/session_store.rb +0 -3
  216. data/spec/rails_app/config/initializers/wrap_parameters.rb +0 -14
  217. data/spec/rails_app/config/initializers/zeitwerk.rb +0 -10
  218. data/spec/rails_app/config/locales/activity_notification.en.yml +0 -26
  219. data/spec/rails_app/config/locales/devise.en.yml +0 -62
  220. data/spec/rails_app/config/mongoid.yml +0 -13
  221. data/spec/rails_app/config/routes.rb +0 -50
  222. data/spec/rails_app/config/secrets.yml +0 -22
  223. data/spec/rails_app/config/webpack/development.js +0 -5
  224. data/spec/rails_app/config/webpack/environment.js +0 -7
  225. data/spec/rails_app/config/webpack/loaders/vue.js +0 -6
  226. data/spec/rails_app/config/webpack/production.js +0 -5
  227. data/spec/rails_app/config/webpack/test.js +0 -5
  228. data/spec/rails_app/config/webpacker.yml +0 -97
  229. data/spec/rails_app/config.ru +0 -4
  230. data/spec/rails_app/db/migrate/20160716000000_create_test_tables.rb +0 -42
  231. data/spec/rails_app/db/migrate/20181209000000_create_activity_notification_tables.rb +0 -33
  232. data/spec/rails_app/db/migrate/20191201000000_add_tokens_to_users.rb +0 -10
  233. data/spec/rails_app/db/schema.rb +0 -98
  234. data/spec/rails_app/db/seeds.rb +0 -95
  235. data/spec/rails_app/lib/custom_optional_targets/console_output.rb +0 -16
  236. data/spec/rails_app/lib/custom_optional_targets/raise_error.rb +0 -14
  237. data/spec/rails_app/lib/custom_optional_targets/wrong_target.rb +0 -13
  238. data/spec/rails_app/lib/mailer_previews/mailer_preview.rb +0 -29
  239. data/spec/rails_app/package.json +0 -23
  240. data/spec/rails_app/postcss.config.js +0 -12
  241. data/spec/rails_app/public/404.html +0 -67
  242. data/spec/rails_app/public/422.html +0 -67
  243. data/spec/rails_app/public/500.html +0 -66
  244. data/spec/rails_app/public/favicon.ico +0 -0
  245. data/spec/roles/acts_as_group_spec.rb +0 -30
  246. data/spec/roles/acts_as_notifiable_spec.rb +0 -432
  247. data/spec/roles/acts_as_notifier_spec.rb +0 -30
  248. data/spec/roles/acts_as_target_spec.rb +0 -36
  249. data/spec/spec_helper.rb +0 -56
  250. data/spec/version_spec.rb +0 -31
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d670191ff319b7ca33142f0f92cdd3d0332287082d4512515175492431a14b2d
4
- data.tar.gz: da39c715f28ae37adc1da46ca3b148b7664627f9e0a9bd924200707340599b18
3
+ metadata.gz: 7ac9e17e63d801b74604936ea099825e1eedd68c5f743b35c01e7af9e73d90ab
4
+ data.tar.gz: 07db03884cf03f86db283a7b5cbd92f7629277ea5f4f5a910629924471ff62e3
5
5
  SHA512:
6
- metadata.gz: 82104cb3b70c4530714d56a91bec204bc7ffc5c4a0f93c6646f483b7eff888b5906c20613410df2df423c155eb8a18771538e30b07c59e07d525dda35afbf9a7
7
- data.tar.gz: c0c9027327dbd0667bced6899cb619a4463bef3ff131c8818d3388ea6e5abeeffa51ce63990f6770210d98d06c504972cc33996593f58f14dce4de4089d95632
6
+ metadata.gz: 91e6206957623ce19971e581afa07fe3b464b02273a968c17bbf145da5836aecbd9b0919b05fae844f912a003a9eeb0d2d4a1df011889ba6bcfb7d752c632346
7
+ data.tar.gz: 0efe5572854a47c271f6b79b1e0ad649b0d491c1ba4b5370a09a78a0876b4f684bf841069fbaba26fff24e3735ae6b11eeb9bf92ce75f359776d59fd526519ce
data/README.md CHANGED
@@ -24,6 +24,7 @@
24
24
  * Grouping notifications (grouping like *"Kevin and 7 other users posted comments to this article"*)
25
25
  * Email notification
26
26
  * Batch email notification (event driven or periodical email notification, daily or weekly etc)
27
+ * Cascading notifications (progressive notification escalation through multiple channels with time delays)
27
28
  * Push notification with [Action Cable](https://guides.rubyonrails.org/action_cable_overview.html)
28
29
  * Subscription management (subscribing and unsubscribing for each target and notification type)
29
30
  * REST API backend and [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification)
@@ -104,6 +105,7 @@ You can see sample single page application using [Vue.js](https://vuejs.org) as
104
105
  - [Batch email subject](/docs/Functions.md#batch-email-subject)
105
106
  - [i18n for batch email](/docs/Functions.md#i18n-for-batch-email)
106
107
  - [Grouping notifications](/docs/Functions.md#grouping-notifications)
108
+ - [Cascading notifications](/docs/Functions.md#cascading-notifications)
107
109
  - [Subscription management](/docs/Functions.md#subscription-management)
108
110
  - [Configuring subscriptions](/docs/Functions.md#configuring-subscriptions)
109
111
  - [Managing subscriptions](/docs/Functions.md#managing-subscriptions)
@@ -325,9 +327,9 @@ See [Testing](/docs/Testing.md#Testing).
325
327
 
326
328
  ## Documentation
327
329
 
328
- See [API Reference](http://www.rubydoc.info/github/simukappu/activity_notification/index) for more details.
330
+ `docs/` contains documentation for users to read. These files are included in the distributed Gem. `ai-docs/` contains AI-generated and design documents. These files are not included in the distributed Gem.
329
331
 
330
- RubyDoc.info does not support parsing methods in *included* and *class_methods* of *ActiveSupport::Concern* currently.
332
+ See [API Reference](http://www.rubydoc.info/github/simukappu/activity_notification/index) for more details. RubyDoc.info does not support parsing methods in *included* and *class_methods* of *ActiveSupport::Concern* currently.
331
333
  To read complete documents, please generate YARD documents on your local environment:
332
334
  ```console
333
335
  $ git pull https://github.com/simukappu/activity_notification.git
@@ -0,0 +1,123 @@
1
+ if defined?(ActiveJob)
2
+ # Job to handle cascading notifications with time delays and read status checking.
3
+ # This job enables sequential delivery of notifications through different channels
4
+ # based on whether previous notifications were read.
5
+ #
6
+ # @example Basic usage
7
+ # cascade_config = [
8
+ # { delay: 10.minutes, target: :slack },
9
+ # { delay: 10.minutes, target: :email }
10
+ # ]
11
+ # CascadingNotificationJob.perform_later(notification.id, cascade_config, 0)
12
+ class ActivityNotification::CascadingNotificationJob < ActivityNotification.config.parent_job.constantize
13
+ queue_as ActivityNotification.config.active_job_queue
14
+
15
+ # Performs a single step in the cascading notification chain.
16
+ # Checks if the notification is still unread, and if so, triggers the next optional target
17
+ # and schedules the next step in the cascade.
18
+ #
19
+ # @param [Integer] notification_id ID of the notification to check
20
+ # @param [Array<Hash>] cascade_config Array of cascade step configurations
21
+ # @option cascade_config [ActiveSupport::Duration] :delay Time to wait before checking and sending
22
+ # @option cascade_config [Symbol, String] :target Name of the optional target to trigger (e.g., :slack, :email)
23
+ # @option cascade_config [Hash] :options Optional parameters to pass to the optional target
24
+ # @param [Integer] step_index Current step index in the cascade chain (0-based)
25
+ # @return [Hash, nil] Result of triggering the optional target, or nil if notification was read or not found
26
+ def perform(notification_id, cascade_config, step_index = 0)
27
+ # Find the notification using ORM-appropriate method
28
+ # :nocov:
29
+ notification = case ActivityNotification.config.orm
30
+ when :dynamoid
31
+ ActivityNotification::Notification.find(notification_id, raise_error: false)
32
+ when :mongoid
33
+ begin
34
+ ActivityNotification::Notification.find(notification_id)
35
+ rescue Mongoid::Errors::DocumentNotFound
36
+ nil
37
+ end
38
+ else
39
+ ActivityNotification::Notification.find_by(id: notification_id)
40
+ end
41
+ # :nocov:
42
+
43
+ # Return early if notification not found or has been opened (read)
44
+ return nil if notification.nil? || notification.opened?
45
+
46
+ # Get current step configuration
47
+ current_step = cascade_config[step_index]
48
+ return nil if current_step.nil?
49
+
50
+ # Extract step parameters
51
+ target_name = current_step[:target] || current_step['target']
52
+ target_options = current_step[:options] || current_step['options'] || {}
53
+
54
+ # Trigger the optional target for this step
55
+ result = trigger_optional_target(notification, target_name, target_options)
56
+
57
+ # Schedule next step if available and notification is still unread
58
+ next_step_index = step_index + 1
59
+ if next_step_index < cascade_config.length
60
+ next_step = cascade_config[next_step_index]
61
+ delay = next_step[:delay] || next_step['delay']
62
+
63
+ if delay.present?
64
+ # Schedule the next step with the specified delay
65
+ self.class.set(wait: delay).perform_later(
66
+ notification_id,
67
+ cascade_config,
68
+ next_step_index
69
+ )
70
+ end
71
+ end
72
+
73
+ result
74
+ end
75
+
76
+ private
77
+
78
+ # Triggers a specific optional target for the notification
79
+ # @param [Notification] notification The notification instance
80
+ # @param [Symbol, String] target_name Name of the optional target
81
+ # @param [Hash] options Options to pass to the optional target
82
+ # @return [Hash] Result of triggering the target
83
+ def trigger_optional_target(notification, target_name, options = {})
84
+ target_name_sym = target_name.to_sym
85
+
86
+ # Get all configured optional targets for this notification
87
+ optional_targets = notification.notifiable.optional_targets(
88
+ notification.target.to_resources_name,
89
+ notification.key
90
+ )
91
+
92
+ # Find the matching optional target
93
+ optional_target = optional_targets.find do |ot|
94
+ ot.to_optional_target_name == target_name_sym
95
+ end
96
+
97
+ if optional_target.nil?
98
+ Rails.logger.warn("Optional target '#{target_name}' not found for notification #{notification.id}")
99
+ return { target_name_sym => :not_configured }
100
+ end
101
+
102
+ # Check subscription status
103
+ unless notification.optional_target_subscribed?(target_name_sym)
104
+ Rails.logger.info("Target not subscribed to optional target '#{target_name}' for notification #{notification.id}")
105
+ return { target_name_sym => :not_subscribed }
106
+ end
107
+
108
+ # Trigger the optional target
109
+ begin
110
+ optional_target.notify(notification, options)
111
+ Rails.logger.info("Successfully triggered optional target '#{target_name}' for notification #{notification.id}")
112
+ { target_name_sym => :success }
113
+ rescue => e
114
+ Rails.logger.error("Failed to trigger optional target '#{target_name}' for notification #{notification.id}: #{e.message}")
115
+ if ActivityNotification.config.rescue_optional_target_errors
116
+ { target_name_sym => e }
117
+ else
118
+ raise e
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
data/docs/Functions.md CHANGED
@@ -99,7 +99,7 @@ If you use i18n for email, you can configure email subject in your locale files.
99
99
 
100
100
  #### Other header fields
101
101
 
102
- Similarly to the [Email subject](#email-subject), the `From`, `Reply-To` and `Message-ID` headers are configurable per notifiable model. From and reply to will override the `config.mailer_sender` config setting.
102
+ Similarly to the [Email subject](#email-subject), the `From`, `Reply-To`, `CC` and `Message-ID` headers are configurable per notifiable model. From and reply to will override the `config.mailer_sender` config setting.
103
103
 
104
104
  ```ruby
105
105
  class Comment < ActiveRecord::Base
@@ -120,12 +120,88 @@ class Comment < ActiveRecord::Base
120
120
  "no-reply.article+comment-#{self.id}@example.com"
121
121
  end
122
122
 
123
+ def overriding_notification_email_cc(target, key)
124
+ # CC the article author on comment notifications
125
+ if key == "comment.create"
126
+ article.user.email
127
+ else
128
+ nil
129
+ end
130
+ end
131
+
123
132
  def overriding_notification_email_message_id(target, key)
124
133
  "https://www.example.com/article/#{article.id}@example.com/"
125
134
  end
126
135
  end
127
136
  ```
128
137
 
138
+ #### CC (Carbon Copy) configuration
139
+
140
+ *activity_notification* supports CC (Carbon Copy) email addresses at three levels with the following priority order:
141
+
142
+ 1. **Notifiable model override** (highest priority) - using `overriding_notification_email_cc` method
143
+ 2. **Target model method** - using `mailer_cc` method
144
+ 3. **Global configuration** - using `config.mailer_cc` setting
145
+
146
+ ##### Global CC configuration
147
+
148
+ You can configure global CC recipients in *activity_notification.rb* initializer as *String*, *Array*, or *Proc*:
149
+
150
+ ```ruby
151
+ # Single CC recipient for all notifications
152
+ config.mailer_cc = 'admin@example.com'
153
+
154
+ # Multiple CC recipients for all notifications
155
+ config.mailer_cc = ['admin@example.com', 'support@example.com']
156
+
157
+ # Dynamic CC based on notification key
158
+ config.mailer_cc = ->(key) {
159
+ if key.include?('urgent')
160
+ ['urgent@example.com', 'manager@example.com']
161
+ else
162
+ 'admin@example.com'
163
+ end
164
+ }
165
+ ```
166
+
167
+ ##### Target-level CC configuration
168
+
169
+ You can define `mailer_cc` method in your target model to set CC recipients for that specific target:
170
+
171
+ ```ruby
172
+ class User < ActiveRecord::Base
173
+ acts_as_target
174
+ belongs_to :team_lead, class_name: 'User'
175
+
176
+ # Return single or multiple CC addresses
177
+ def mailer_cc
178
+ team_lead.present? ? team_lead.email : 'admin@example.com'
179
+ end
180
+ end
181
+ ```
182
+
183
+ ##### Notifiable-level CC override
184
+
185
+ For the most granular control, implement `overriding_notification_email_cc` in your notifiable model to set CC per notification type:
186
+
187
+ ```ruby
188
+ class Article < ActiveRecord::Base
189
+ acts_as_notifiable :users,
190
+ targets: ->(article, key) { [article.user] }
191
+
192
+ def overriding_notification_email_cc(target, key)
193
+ case key
194
+ when 'article.published'
195
+ ['editor@example.com', 'marketing@example.com']
196
+ when 'article.flagged'
197
+ 'moderation@example.com'
198
+ else
199
+ nil # Falls back to target's mailer_cc or global config
200
+ end
201
+ end
202
+ end
203
+ ```
204
+
129
205
  #### i18n for email
130
206
 
131
207
  The subject of notification email can be put in your locale *.yml* files as **mail_subject** field:
@@ -263,6 +339,126 @@ notification:
263
339
 
264
340
  Then, you will see *"Kevin and 7 other users posted 10 comments to your article"*.
265
341
 
342
+ ### Cascading notifications
343
+
344
+ *activity_notification* provides cascading notifications that enable progressive notification escalation through multiple channels with time delays. This ensures important notifications are not missed while avoiding unnecessary interruptions when users have already engaged with earlier notification channels.
345
+
346
+ #### How cascading notifications work
347
+
348
+ Cascading notifications automatically send notifications through different channels (Slack, Email, SMS, etc.) with configurable time delays, but only if the user hasn't already read the notification:
349
+
350
+ 1. User gets an in-app notification
351
+ 2. ⏱️ Wait 10 minutes → Still unread? Send Slack message
352
+ 3. ⏱️ Wait 10 more minutes → Still unread? Send Email
353
+ 4. ⏱️ Wait 30 more minutes → Still unread? Send SMS
354
+
355
+ If the user reads the notification at any point, the cascade stops automatically.
356
+
357
+ #### Basic usage
358
+
359
+ ```ruby
360
+ # Create a notification
361
+ notification = Notification.create!(
362
+ target: user,
363
+ notifiable: comment,
364
+ key: 'comment.reply'
365
+ )
366
+
367
+ # Setup cascade: Slack after 10 min, Email after another 10 min
368
+ cascade_config = [
369
+ { delay: 10.minutes, target: :slack },
370
+ { delay: 10.minutes, target: :email }
371
+ ]
372
+
373
+ # Start the cascade
374
+ notification.cascade_notify(cascade_config)
375
+ ```
376
+
377
+ #### Configuration options
378
+
379
+ Each step in the cascade requires:
380
+
381
+ | Parameter | Type | Required | Description |
382
+ |-----------|------|----------|-------------|
383
+ | `delay` | Duration | Yes | How long to wait (e.g., `10.minutes`, `1.hour`) |
384
+ | `target` | Symbol/String | Yes | Optional target name (`:slack`, `:email`, etc.) |
385
+ | `options` | Hash | No | Custom options to pass to the target |
386
+
387
+ #### Advanced usage
388
+
389
+ **Immediate first notification:**
390
+ ```ruby
391
+ # Send Slack immediately, then email if still unread
392
+ cascade_config = [
393
+ { delay: 5.minutes, target: :slack },
394
+ { delay: 10.minutes, target: :email }
395
+ ]
396
+
397
+ notification.cascade_notify(cascade_config, trigger_first_immediately: true)
398
+ ```
399
+
400
+ **With custom options:**
401
+ ```ruby
402
+ cascade_config = [
403
+ {
404
+ delay: 5.minutes,
405
+ target: :slack,
406
+ options: { channel: '#urgent' }
407
+ },
408
+ {
409
+ delay: 10.minutes,
410
+ target: :email
411
+ }
412
+ ]
413
+
414
+ notification.cascade_notify(cascade_config)
415
+ ```
416
+
417
+ **Integration with notification creation:**
418
+ ```ruby
419
+ # In your controller
420
+ comment = Comment.create!(comment_params)
421
+
422
+ # Create notifications
423
+ comment.notify(:users, key: 'comment.new')
424
+
425
+ # Add cascade to all created notifications
426
+ comment.notifications.each do |notification|
427
+ cascade_config = [
428
+ { delay: 10.minutes, target: :slack },
429
+ { delay: 30.minutes, target: :email }
430
+ ]
431
+ notification.cascade_notify(cascade_config)
432
+ end
433
+ ```
434
+
435
+ #### Prerequisites
436
+
437
+ Before using cascading notifications, ensure:
438
+
439
+ 1. **Optional targets are configured** on your notifiable models
440
+ 2. **ActiveJob is configured** (default in Rails)
441
+ 3. **Job queue is running** (Sidekiq, Delayed Job, etc.)
442
+
443
+ #### Common patterns
444
+
445
+ **Urgent notifications (fast escalation):**
446
+ ```ruby
447
+ URGENT_CASCADE = [
448
+ { delay: 2.minutes, target: :slack },
449
+ { delay: 5.minutes, target: :email },
450
+ { delay: 10.minutes, target: :sms }
451
+ ].freeze
452
+ ```
453
+
454
+ **Normal notifications (gentle escalation):**
455
+ ```ruby
456
+ NORMAL_CASCADE = [
457
+ { delay: 30.minutes, target: :slack },
458
+ { delay: 1.hour, target: :email }
459
+ ].freeze
460
+ ```
461
+
266
462
 
267
463
  ### Subscription management
268
464
 
@@ -0,0 +1,208 @@
1
+ module ActivityNotification
2
+ # Defines API for cascading notifications included in Notification model.
3
+ # Cascading notifications enable sequential delivery through different channels
4
+ # based on read status, with configurable time delays between each step.
5
+ module CascadingNotificationApi
6
+ extend ActiveSupport::Concern
7
+
8
+ # Starts a cascading notification chain with the specified configuration.
9
+ # The chain will automatically check the read status before each step and
10
+ # only proceed if the notification remains unread.
11
+ #
12
+ # @example Simple cascade with Slack then email
13
+ # notification.cascade_notify([
14
+ # { delay: 10.minutes, target: :slack },
15
+ # { delay: 10.minutes, target: :email }
16
+ # ])
17
+ #
18
+ # @example Cascade with custom options for each target
19
+ # notification.cascade_notify([
20
+ # { delay: 5.minutes, target: :slack, options: { channel: '#alerts' } },
21
+ # { delay: 10.minutes, target: :amazon_sns, options: { subject: 'Urgent' } },
22
+ # { delay: 15.minutes, target: :email }
23
+ # ])
24
+ #
25
+ # @param [Array<Hash>] cascade_config Array of cascade step configurations
26
+ # @option cascade_config [ActiveSupport::Duration] :delay Required. Time to wait before this step
27
+ # @option cascade_config [Symbol, String] :target Required. Name of the optional target (e.g., :slack, :email)
28
+ # @option cascade_config [Hash] :options Optional. Parameters to pass to the optional target
29
+ # @param [Hash] options Additional options for cascade
30
+ # @option options [Boolean] :validate (true) Whether to validate the cascade configuration
31
+ # @option options [Boolean] :trigger_first_immediately (false) Whether to trigger the first target immediately without delay
32
+ # @return [Boolean] true if cascade was initiated successfully, false otherwise
33
+ # @raise [ArgumentError] if cascade_config is invalid and :validate is true
34
+ def cascade_notify(cascade_config, options = {})
35
+ validate = options.fetch(:validate, true)
36
+ trigger_first_immediately = options.fetch(:trigger_first_immediately, false)
37
+
38
+ # Validate configuration if requested
39
+ if validate
40
+ validation_result = validate_cascade_config(cascade_config)
41
+ unless validation_result[:valid]
42
+ raise ArgumentError, "Invalid cascade configuration: #{validation_result[:errors].join(', ')}"
43
+ end
44
+ end
45
+
46
+ # Return false if cascade config is empty
47
+ return false if cascade_config.blank?
48
+
49
+ # Return false if notification is already opened
50
+ return false if opened?
51
+
52
+ if defined?(ActiveJob) && defined?(ActivityNotification::CascadingNotificationJob) &&
53
+ ActivityNotification::CascadingNotificationJob.respond_to?(:perform_later)
54
+ if trigger_first_immediately && cascade_config.any?
55
+ # Trigger first target immediately
56
+ first_step = cascade_config.first
57
+ target_name = first_step[:target] || first_step['target']
58
+ target_options = first_step[:options] || first_step['options'] || {}
59
+
60
+ # Perform the first step synchronously
61
+ perform_cascade_step(target_name, target_options)
62
+
63
+ # Schedule remaining steps if any
64
+ if cascade_config.length > 1
65
+ remaining_config = cascade_config[1..-1]
66
+ first_delay = remaining_config.first[:delay] || remaining_config.first['delay']
67
+
68
+ if first_delay.present?
69
+ ActivityNotification::CascadingNotificationJob
70
+ .set(wait: first_delay)
71
+ .perform_later(id, remaining_config, 0)
72
+ end
73
+ end
74
+ else
75
+ # Schedule first step with its configured delay
76
+ first_step = cascade_config.first
77
+ first_delay = first_step[:delay] || first_step['delay']
78
+
79
+ if first_delay.present?
80
+ ActivityNotification::CascadingNotificationJob
81
+ .set(wait: first_delay)
82
+ .perform_later(id, cascade_config, 0)
83
+ else
84
+ # If no delay specified for first step, trigger immediately
85
+ ActivityNotification::CascadingNotificationJob
86
+ .perform_later(id, cascade_config, 0)
87
+ end
88
+ end
89
+
90
+ true
91
+ else
92
+ Rails.logger.error("ActiveJob or CascadingNotificationJob not available for cascading notifications")
93
+ false
94
+ end
95
+ end
96
+
97
+ # Validates a cascade configuration array
98
+ #
99
+ # @param [Array<Hash>] cascade_config The configuration to validate
100
+ # @return [Hash] Hash with :valid (Boolean) and :errors (Array<String>) keys
101
+ def validate_cascade_config(cascade_config)
102
+ errors = []
103
+
104
+ if cascade_config.nil?
105
+ errors << "cascade_config cannot be nil"
106
+ return { valid: false, errors: errors }
107
+ end
108
+
109
+ unless cascade_config.is_a?(Array)
110
+ errors << "cascade_config must be an Array"
111
+ return { valid: false, errors: errors }
112
+ end
113
+
114
+ if cascade_config.empty?
115
+ errors << "cascade_config cannot be empty"
116
+ end
117
+
118
+ cascade_config.each_with_index do |step, index|
119
+ unless step.is_a?(Hash)
120
+ errors << "Step #{index} must be a Hash"
121
+ next
122
+ end
123
+
124
+ # Check for required target parameter
125
+ target = step[:target] || step['target']
126
+ if target.nil?
127
+ errors << "Step #{index} missing required :target parameter"
128
+ elsif !target.is_a?(Symbol) && !target.is_a?(String)
129
+ errors << "Step #{index} :target must be a Symbol or String"
130
+ end
131
+
132
+ # Check for delay parameter (only required for steps after the first if not using trigger_first_immediately)
133
+ delay = step[:delay] || step['delay']
134
+ if delay.nil?
135
+ errors << "Step #{index} missing :delay parameter"
136
+ elsif !delay.respond_to?(:from_now) && !delay.is_a?(Numeric)
137
+ errors << "Step #{index} :delay must be an ActiveSupport::Duration or Numeric (seconds)"
138
+ end
139
+
140
+ # Check options if present
141
+ options = step[:options] || step['options']
142
+ if options.present? && !options.is_a?(Hash)
143
+ errors << "Step #{index} :options must be a Hash"
144
+ end
145
+ end
146
+
147
+ { valid: errors.empty?, errors: errors }
148
+ end
149
+
150
+ # Checks if a cascading notification is currently in progress for this notification
151
+ # This is a helper method that checks if there are scheduled jobs for this notification
152
+ #
153
+ # @return [Boolean] true if cascade jobs are scheduled (this is a best-effort check)
154
+ def cascade_in_progress?
155
+ # This is a best-effort check that returns false by default
156
+ # In production, you might want to track this state differently
157
+ # (e.g., in Redis, database flag, or by querying the job queue)
158
+ false
159
+ end
160
+
161
+ private
162
+
163
+ # Performs a single cascade step immediately (synchronously)
164
+ # @api private
165
+ # @param [Symbol, String] target_name Name of the optional target
166
+ # @param [Hash] options Options to pass to the optional target
167
+ # @return [Hash] Result of the operation
168
+ def perform_cascade_step(target_name, options = {})
169
+ target_name_sym = target_name.to_sym
170
+
171
+ # Get all configured optional targets for this notification
172
+ optional_targets = notifiable.optional_targets(
173
+ target.to_resources_name,
174
+ key
175
+ )
176
+
177
+ # Find the matching optional target
178
+ optional_target = optional_targets.find do |ot|
179
+ ot.to_optional_target_name == target_name_sym
180
+ end
181
+
182
+ if optional_target.nil?
183
+ Rails.logger.warn("Optional target '#{target_name}' not found for notification #{id}")
184
+ return { target_name_sym => :not_configured }
185
+ end
186
+
187
+ # Check subscription status
188
+ unless optional_target_subscribed?(target_name_sym)
189
+ Rails.logger.info("Target not subscribed to optional target '#{target_name}' for notification #{id}")
190
+ return { target_name_sym => :not_subscribed }
191
+ end
192
+
193
+ # Trigger the optional target
194
+ begin
195
+ optional_target.notify(self, options)
196
+ Rails.logger.info("Successfully triggered optional target '#{target_name}' for notification #{id}")
197
+ { target_name_sym => :success }
198
+ rescue => e
199
+ Rails.logger.error("Failed to trigger optional target '#{target_name}' for notification #{id}: #{e.message}")
200
+ if ActivityNotification.config.rescue_optional_target_errors
201
+ { target_name_sym => e }
202
+ else
203
+ raise e
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -1,7 +1,10 @@
1
+ require 'activity_notification/apis/cascading_notification_api'
2
+
1
3
  module ActivityNotification
2
4
  # Defines API for notification included in Notification model.
3
5
  module NotificationApi
4
6
  extend ActiveSupport::Concern
7
+ include CascadingNotificationApi
5
8
 
6
9
  included do
7
10
  # Defines store_notification as private clas method
@@ -82,6 +82,15 @@ module ActivityNotification
82
82
  # @return [String] Email address as sender of notification email.
83
83
  attr_accessor :mailer_sender
84
84
 
85
+ # @overload mailer_cc
86
+ # Returns carbon copy (CC) email address(es) for notification email
87
+ # @return [String, Array<String>, Proc] CC email address(es) for notification email.
88
+ # @overload mailer_cc=(value)
89
+ # Sets carbon copy (CC) email address(es) for notification email
90
+ # @param [String, Array<String>, Proc] mailer_cc The new mailer_cc
91
+ # @return [String, Array<String>, Proc] CC email address(es) for notification email.
92
+ attr_accessor :mailer_cc
93
+
85
94
  # @overload mailer
86
95
  # Returns mailer class for email notification
87
96
  # @return [String] Mailer class for email notification.
@@ -236,6 +245,7 @@ module ActivityNotification
236
245
  @subscribe_to_email_as_default = nil
237
246
  @subscribe_to_optional_targets_as_default = nil
238
247
  @mailer_sender = nil
248
+ @mailer_cc = nil
239
249
  @mailer = 'ActivityNotification::Mailer'
240
250
  @parent_mailer = 'ActionMailer::Base'
241
251
  @parent_job = 'ActiveJob::Base'
@@ -74,6 +74,7 @@ module ActivityNotification
74
74
  subject: :subject_for,
75
75
  from: :mailer_from,
76
76
  reply_to: :mailer_reply_to,
77
+ cc: :mailer_cc,
77
78
  message_id: nil
78
79
  }.each do |header_name, default_method|
79
80
  overridding_method_name = "overriding_notification_email_#{header_name.to_s}"
@@ -81,7 +82,12 @@ module ActivityNotification
81
82
  @notification.notifiable.send(overridding_method_name, @target, key).present?
82
83
  @notification.notifiable.send(overridding_method_name, @target, key)
83
84
  elsif default_method
84
- send(default_method, key)
85
+ # Special handling for methods that take target instead of key
86
+ if [:mailer_cc].include?(default_method)
87
+ send(default_method, @target)
88
+ else
89
+ send(default_method, key)
90
+ end
85
91
  else
86
92
  nil
87
93
  end
@@ -99,6 +105,26 @@ module ActivityNotification
99
105
  target.mailer_to
100
106
  end
101
107
 
108
+ # Returns carbon copy (CC) email address(es).
109
+ #
110
+ # @param [Object] target Target instance to notify
111
+ # @return [String, Array<String>, nil] CC email address(es) or nil
112
+ def mailer_cc(target)
113
+ if target.respond_to?(:mailer_cc)
114
+ target.mailer_cc
115
+ elsif ActivityNotification.config.mailer_cc.present?
116
+ if ActivityNotification.config.mailer_cc.is_a?(Proc)
117
+ # Get the notification key from current context
118
+ key = @notification ? @notification.key : nil
119
+ ActivityNotification.config.mailer_cc.call(key)
120
+ else
121
+ ActivityNotification.config.mailer_cc
122
+ end
123
+ else
124
+ nil
125
+ end
126
+ end
127
+
102
128
  # Returns sender email address as 'reply_to'.
103
129
  #
104
130
  # @param [String] key Key of the notification or batch notification email
@@ -1,3 +1,3 @@
1
1
  module ActivityNotification
2
- VERSION = "2.4.1"
2
+ VERSION = "2.5.0"
3
3
  end