activity_notification 2.3.3 → 2.4.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build.yml +8 -41
  3. data/CHANGELOG.md +31 -0
  4. data/Gemfile +1 -3
  5. data/README.md +30 -28
  6. data/activity_notification.gemspec +5 -5
  7. data/ai-curated-specs/issues/172/design.md +220 -0
  8. data/ai-curated-specs/issues/172/tasks.md +326 -0
  9. data/ai-curated-specs/issues/188/design.md +227 -0
  10. data/ai-curated-specs/issues/188/requirements.md +78 -0
  11. data/ai-curated-specs/issues/188/tasks.md +203 -0
  12. data/ai-curated-specs/issues/188/upstream-contributions.md +592 -0
  13. data/ai-curated-specs/issues/50/design.md +235 -0
  14. data/ai-curated-specs/issues/50/requirements.md +49 -0
  15. data/ai-curated-specs/issues/50/tasks.md +232 -0
  16. data/app/controllers/activity_notification/notifications_api_controller.rb +22 -0
  17. data/app/controllers/activity_notification/notifications_controller.rb +27 -1
  18. data/app/mailers/activity_notification/mailer.rb +2 -2
  19. data/app/views/activity_notification/notifications/default/_index.html.erb +6 -1
  20. data/app/views/activity_notification/notifications/default/destroy_all.js.erb +6 -0
  21. data/docs/Functions.md +5 -11
  22. data/docs/Setup.md +73 -4
  23. data/docs/Testing.md +12 -1
  24. data/gemfiles/Gemfile.rails-7.0 +2 -0
  25. data/gemfiles/Gemfile.rails-8.0 +0 -2
  26. data/lib/activity_notification/apis/notification_api.rb +51 -2
  27. data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
  28. data/lib/activity_notification/helpers/view_helpers.rb +28 -0
  29. data/lib/activity_notification/mailers/helpers.rb +14 -7
  30. data/lib/activity_notification/models/concerns/swagger/subscription_schema.rb +1 -1
  31. data/lib/activity_notification/models/concerns/target.rb +16 -0
  32. data/lib/activity_notification/models.rb +1 -1
  33. data/lib/activity_notification/notification_resilience.rb +115 -0
  34. data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
  35. data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
  36. data/lib/activity_notification/orm/dynamoid.rb +42 -6
  37. data/lib/activity_notification/rails/routes.rb +1 -0
  38. data/lib/activity_notification/version.rb +1 -1
  39. data/lib/activity_notification.rb +1 -0
  40. data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
  41. data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
  42. data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
  43. data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
  44. data/spec/concerns/apis/notification_api_spec.rb +161 -5
  45. data/spec/concerns/models/target_spec.rb +7 -0
  46. data/spec/controllers/controller_spec_utility.rb +2 -2
  47. data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
  48. data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
  49. data/spec/generators/migration/migration_generator_spec.rb +18 -4
  50. data/spec/helpers/view_helpers_spec.rb +14 -0
  51. data/spec/jobs/notification_resilience_job_spec.rb +167 -0
  52. data/spec/mailers/notification_resilience_spec.rb +263 -0
  53. data/spec/models/notification_spec.rb +1 -1
  54. data/spec/models/subscription_spec.rb +1 -1
  55. data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
  56. data/spec/rails_app/config/application.rb +1 -0
  57. data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
  58. metadata +35 -22
data/docs/Functions.md CHANGED
@@ -381,8 +381,6 @@ end
381
381
 
382
382
  Then, you can access *users/1/subscriptions* and use *[ActivityNotification::SubscriptionsController](/app/controllers/activity_notification/subscriptions_controller.rb)* or *[ActivityNotification::SubscriptionsWithDeviseController](/app/controllers/activity_notification/subscriptions_with_devise_controller.rb)* to manage the subscriptions.
383
383
 
384
- You can see sample subscription management view in demo application here: *https://activity-notification-example.herokuapp.com/users/1/subscriptions*
385
-
386
384
  If you would like to customize subscription controllers or views, you can use generators like notifications:
387
385
 
388
386
  * Customize subscription controllers
@@ -436,8 +434,6 @@ You can see [sample single page application](/spec/rails_app/app/javascript/) us
436
434
 
437
435
  *activity_notification* provides API reference as [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification).
438
436
 
439
- OpenAPI Specification in [online demo](https://activity-notification-example.herokuapp.com/) is published here: **https://activity-notification-example.herokuapp.com/api/v2/apidocs**
440
-
441
437
  Public API reference is also hosted in [SwaggerHub](https://swagger.io/tools/swaggerhub/) here: **https://app.swaggerhub.com/apis-docs/simukappu/activity-notification/**
442
438
 
443
439
  You can also publish OpenAPI Specification in your own application using *[ActivityNotification::ApidocsController](/app/controllers/activity_notification/apidocs_controller.rb)* like this:
@@ -588,7 +584,7 @@ end
588
584
  To sign in and get *access-token* from Devise Token Auth, call *sign_in* API which you configured by *mount_devise_token_auth_for* method:
589
585
 
590
586
  ```console
591
- $ curl -X POST -H "Content-Type: application/json" -D - -d '{"email": "ichiro@example.com","password": "changeit"}' https://activity-notification-example.herokuapp.com/api/v2/auth/sign_in
587
+ $ curl -X POST -H "Content-Type: application/json" -D - -d '{"email": "ichiro@example.com","password": "changeit"}' https://localhost:3000/api/v2/auth/sign_in
592
588
 
593
589
 
594
590
  HTTP/1.1 200 OK
@@ -615,7 +611,7 @@ uid: ichiro@example.com
615
611
  Then, call *activity_notification* API with returned *access-token*, *client* and *uid* as HTTP headers:
616
612
 
617
613
  ```console
618
- $ curl -X GET -H "Content-Type: application/json" -H "access-token: ZiDvw8vJGtbESy5Qpw32Kw" -H "client: W0NkGrTS88xeOx4VDOS-Xg" -H "uid: ichiro@example.com" -D - https://activity-notification-example.herokuapp.com/api/v2/notifications
614
+ $ curl -X GET -H "Content-Type: application/json" -H "access-token: ZiDvw8vJGtbESy5Qpw32Kw" -H "client: W0NkGrTS88xeOx4VDOS-Xg" -H "uid: ichiro@example.com" -D - https://localhost:3000/api/v2/notifications
619
615
 
620
616
  HTTP/1.1 200 OK
621
617
  ...
@@ -631,7 +627,7 @@ HTTP/1.1 200 OK
631
627
  Without valid *access-token*, API returns *401 Unauthorized*:
632
628
 
633
629
  ```console
634
- $ curl -X GET -H "Content-Type: application/json" -D - https://activity-notification-example.herokuapp.com/api/v2/notifications
630
+ $ curl -X GET -H "Content-Type: application/json" -D - https://localhost:3000/api/v2/notifications
635
631
 
636
632
  HTTP/1.1 401 Unauthorized
637
633
  ...
@@ -646,7 +642,7 @@ HTTP/1.1 401 Unauthorized
646
642
  When you request restricted resources of unauthorized targets, *activity_notification* API returns *403 Forbidden*:
647
643
 
648
644
  ```console
649
- $ curl -X GET -H "Content-Type: application/json" -H "access-token: ZiDvw8vJGtbESy5Qpw32Kw" -H "client: W0NkGrTS88xeOx4VDOS-Xg" -H "uid: ichiro@example.com" -D - https://activity-notification-example.herokuapp.com/api/v2/notifications/1
645
+ $ curl -X GET -H "Content-Type: application/json" -H "access-token: ZiDvw8vJGtbESy5Qpw32Kw" -H "client: W0NkGrTS88xeOx4VDOS-Xg" -H "uid: ichiro@example.com" -D - https://localhost:3000/api/v2/notifications/1
650
646
 
651
647
  HTTP/1.1 403 Forbidden
652
648
  ...
@@ -1141,6 +1137,4 @@ user.find_or_create_subscription('comment.reply').subscribe_to_optional_target(:
1141
1137
  user.find_or_create_subscription('comment.reply').unsubscribe_to_optional_target(:slack)
1142
1138
  ```
1143
1139
 
1144
- You can also manage subscriptions of optional targets by subscriptions REST API. See [REST API backend](#rest-api-backend) for more details.
1145
-
1146
- You can see sample subscription management view in demo application here: *https://activity-notification-example.herokuapp.com/users/1/subscriptions*
1140
+ You can also manage subscriptions of optional targets by subscriptions REST API. See [REST API backend](#rest-api-backend) for more details.
data/docs/Setup.md CHANGED
@@ -22,6 +22,24 @@ $ bin/rails generate activity_notification:install
22
22
  The generator will install an initializer which describes all configuration options of *activity_notification*.
23
23
  It also generates a i18n based translation file which we can configure the presentation of notifications.
24
24
 
25
+ #### ORM Dependencies
26
+
27
+ By default, *activity_notification* uses **ActiveRecord** as the ORM and no additional ORM gems are required.
28
+
29
+ If you intend to use **Mongoid** support, you need to add the `mongoid` gem separately to your Gemfile:
30
+
31
+ ```ruby
32
+ gem 'activity_notification'
33
+ gem 'mongoid', '>= 4.0.0', '< 10.0'
34
+ ```
35
+
36
+ If you intend to use **Dynamoid** support for Amazon DynamoDB, you need to add the `dynamoid` gem separately to your Gemfile:
37
+
38
+ ```ruby
39
+ gem 'activity_notification'
40
+ gem 'dynamoid', '>= 3.11.0', '< 4.0'
41
+ ```
42
+
25
43
  ### Database setup
26
44
 
27
45
  #### Using ActiveRecord ORM
@@ -64,7 +82,14 @@ config.yaml_column_permitted_classes << Time
64
82
 
65
83
  #### Using Mongoid ORM
66
84
 
67
- When you use *activity_notification* with [Mongoid](http://mongoid.org) ORM, set **AN_ORM** environment variable to **mongoid**:
85
+ When you use *activity_notification* with [Mongoid](http://mongoid.org) ORM, you first need to add the `mongoid` gem to your Gemfile:
86
+
87
+ ```ruby
88
+ gem 'activity_notification'
89
+ gem 'mongoid', '>= 4.0.0', '< 10.0'
90
+ ```
91
+
92
+ Then set **AN_ORM** environment variable to **mongoid**:
68
93
 
69
94
  ```console
70
95
  $ export AN_ORM=mongoid
@@ -80,13 +105,14 @@ You need to configure Mongoid in your Rails application for your MongoDB environ
80
105
 
81
106
  #### Using Dynamoid ORM
82
107
 
83
- Currently, *activity_notification* only works with Dynamoid 3.1.0.
108
+ When you use *activity_notification* with [Dynamoid](https://github.com/Dynamoid/dynamoid) ORM, you first need to add the `dynamoid` gem to your Gemfile:
84
109
 
85
110
  ```ruby
86
- gem 'dynamoid', '3.1.0'
111
+ gem 'activity_notification'
112
+ gem 'dynamoid', '>= 3.11.0', '< 4.0'
87
113
  ```
88
114
 
89
- When you use *activity_notification* with [Dynamoid](https://github.com/Dynamoid/dynamoid) ORM, set **AN_ORM** environment variable to **dynamoid**:
115
+ Then set **AN_ORM** environment variable to **dynamoid**:
90
116
 
91
117
  ```console
92
118
  $ export AN_ORM=dynamoid
@@ -762,6 +788,49 @@ notification:
762
788
 
763
789
  This structure is valid for notifications with keys *"notification.comment.reply"* or *"comment.reply"*. As mentioned before, *"notification."* part of the key is optional. In addition for above example, `%{notifier_name}` and `%{article_title}` are used from parameter field in the notification record. Pluralization is supported (but optional) for grouped notifications using the `%{group_notification_count}` value.
764
790
 
791
+ ### Managing notifications
792
+
793
+ *activity_notification* provides several methods to manage notifications programmatically. The most common operation is opening notifications to mark them as read.
794
+
795
+ #### Opening notifications
796
+
797
+ You can mark individual notifications as opened (read) using the **open!** method:
798
+
799
+ ```ruby
800
+ # Open a single notification
801
+ notification = current_user.notifications.first
802
+ notification.open!
803
+
804
+ # Open notification with specific timestamp
805
+ notification.open!(opened_at: 1.hour.ago)
806
+
807
+ # Open notification with opening group members
808
+ notification.open!(with_members: true)
809
+
810
+ # Open notification skipping validations when the associated notifiable record may have been deleted
811
+ notification.open!(skip_validation: true)
812
+ ```
813
+
814
+ The **open!** method accepts the following options:
815
+
816
+ * **:opened_at** (Time) - Time to set as the opened timestamp (defaults to `Time.current`)
817
+ * **:with_members** (Boolean) - Whether to open group member notifications as well (defaults to `false`)
818
+ * **:skip_validation** (Boolean) - Whether to skip ActiveRecord validations when updating (defaults to `false`). Useful when the associated notifiable record may have been deleted but the notification still exists.
819
+
820
+ You can also open all notifications for a target:
821
+
822
+ ```ruby
823
+ # Open all unopened notifications for a user
824
+ ActivityNotification::Notification.open_all_of(current_user)
825
+
826
+ # Open notifications with filters
827
+ ActivityNotification::Notification.open_all_of(
828
+ current_user,
829
+ filtered_by_type: 'Comment',
830
+ opened_at: 1.hour.ago
831
+ )
832
+ ```
833
+
765
834
  ### Customizing controllers (optional)
766
835
 
767
836
  If the customization at the views level is not enough, you can customize each controller by following these steps:
data/docs/Testing.md CHANGED
@@ -104,6 +104,17 @@ $ bin/rails server
104
104
  ```
105
105
  Then, you can access <http://localhost:3000> for the example application.
106
106
 
107
+ ##### Default test users
108
+
109
+ Login as the following test users to experience user activity notifications:
110
+
111
+ | Email | Password | Admin? |
112
+ |:---:|:---:|:---:|
113
+ | ichiro@example.com | changeit | Yes |
114
+ | stephen@example.com | changeit | |
115
+ | klay@example.com | changeit | |
116
+ | kevin@example.com | changeit | |
117
+
107
118
  ##### Run with your local database
108
119
  As default, example Rails application runs with local SQLite database in *spec/rails_app/db/development.sqlite3*.
109
120
  This application supports to run with your local MySQL, PostgreSQL, MongoDB.
@@ -144,5 +155,5 @@ $ cd spec/rails_app
144
155
  $ # You don't need migration when you use MongoDB only (AN_ORM=mongoid and AN_TEST_DB=mongodb)
145
156
  $ bin/rake db:migrate
146
157
  $ bin/rake db:seed
147
- $ bin/rails server Puma
158
+ $ bin/rails server
148
159
  ```
@@ -4,6 +4,8 @@ gemspec path: '../'
4
4
 
5
5
  gem 'rails', '~> 7.0.0'
6
6
  gem 'sprockets-rails'
7
+ gem 'concurrent-ruby', '<= 1.3.4'
8
+ gem 'sqlite3', '~> 1.4'
7
9
 
8
10
  group :development do
9
11
  gem 'bullet'
@@ -4,8 +4,6 @@ gemspec path: '../'
4
4
 
5
5
  gem 'rails', '~> 8.0.0'
6
6
  gem 'sprockets-rails'
7
- # https://github.com/lynndylanhurley/devise_token_auth/pull/1639
8
- gem 'devise_token_auth', git: 'https://github.com/lynndylanhurley/devise_token_auth.git'
9
7
 
10
8
  group :development do
11
9
  gem 'bullet'
@@ -411,15 +411,63 @@ module ActivityNotification
411
411
  # @option options [String] :filtered_by_key (nil) Key of the notification for filter
412
412
  # @option options [String] :later_than (nil) ISO 8601 format time to filter notification index later than specified time
413
413
  # @option options [String] :earlier_than (nil) ISO 8601 format time to filter notification index earlier than specified time
414
+ # @option options [Array] :ids (nil) Array of specific notification IDs to open
414
415
  # @return [Array<Notification>] Opened notification records
415
416
  def open_all_of(target, options = {})
416
417
  opened_at = options[:opened_at] || Time.current
417
418
  target_unopened_notifications = target.notifications.unopened_only.filtered_by_options(options)
419
+ # If specific IDs are provided, filter by them
420
+ if options[:ids].present?
421
+ # :nocov:
422
+ case ActivityNotification.config.orm
423
+ when :mongoid
424
+ target_unopened_notifications = target_unopened_notifications.where(id: { '$in' => options[:ids] })
425
+ when :dynamoid
426
+ target_unopened_notifications = target_unopened_notifications.where('id.in': options[:ids])
427
+ else # :active_record
428
+ target_unopened_notifications = target_unopened_notifications.where(id: options[:ids])
429
+ end
430
+ # :nocov:
431
+ end
418
432
  opened_notifications = target_unopened_notifications.to_a.map { |n| n.opened_at = opened_at; n }
419
433
  target_unopened_notifications.update_all(opened_at: opened_at)
420
434
  opened_notifications
421
435
  end
422
436
 
437
+ # Destroys all notifications of the target matching the filter criteria.
438
+ #
439
+ # @param [Object] target Target of the notifications to destroy
440
+ # @param [Hash] options Options for filtering notifications to destroy
441
+ # @option options [String] :filtered_by_type (nil) Notifiable type for filter
442
+ # @option options [Object] :filtered_by_group (nil) Group instance for filter
443
+ # @option options [String] :filtered_by_group_type (nil) Group type for filter, valid with :filtered_by_group_id
444
+ # @option options [String] :filtered_by_group_id (nil) Group instance id for filter, valid with :filtered_by_group_type
445
+ # @option options [String] :filtered_by_key (nil) Key of the notification for filter
446
+ # @option options [String] :later_than (nil) ISO 8601 format time to filter notifications later than specified time
447
+ # @option options [String] :earlier_than (nil) ISO 8601 format time to filter notifications earlier than specified time
448
+ # @option options [Array] :ids (nil) Array of specific notification IDs to destroy
449
+ # @return [Array<Notification>] Destroyed notification records
450
+ def destroy_all_of(target, options = {})
451
+ target_notifications = target.notifications.filtered_by_options(options)
452
+ # If specific IDs are provided, filter by them
453
+ if options[:ids].present?
454
+ # :nocov:
455
+ case ActivityNotification.config.orm
456
+ when :mongoid
457
+ target_notifications = target_notifications.where(id: { '$in' => options[:ids] })
458
+ when :dynamoid
459
+ target_notifications = target_notifications.where('id.in': options[:ids])
460
+ else # :active_record
461
+ target_notifications = target_notifications.where(id: options[:ids])
462
+ end
463
+ # :nocov:
464
+ end
465
+ # Get the notifications before destroying them for return value
466
+ destroyed_notifications = target_notifications.to_a
467
+ target_notifications.destroy_all
468
+ destroyed_notifications
469
+ end
470
+
423
471
  # Returns if group member of the notifications exists.
424
472
  # This method is designed to be called from controllers or views to avoid N+1.
425
473
  #
@@ -517,7 +565,7 @@ module ActivityNotification
517
565
  # @param [Hash] options Options for notification email
518
566
  # @option options [Boolean] :send_later If it sends notification email asynchronously
519
567
  # @option options [String, Symbol] :fallback (:default) Fallback template to use when MissingTemplate is raised
520
- # @return [Mail::Message, ActionMailer::DeliveryJob] Email message or its delivery job
568
+ # @return [Mail::Message, ActionMailer::DeliveryJob, NilClass] Email message, its delivery job, or nil if notification not found
521
569
  def send_notification_email(options = {})
522
570
  if target.notification_email_allowed?(notifiable, key) &&
523
571
  notifiable.notification_email_allowed?(target, key) &&
@@ -559,6 +607,7 @@ module ActivityNotification
559
607
  # @param [Hash] options Options for opening notifications
560
608
  # @option options [DateTime] :opened_at (Time.current) Time to set to opened_at of the notification record
561
609
  # @option options [Boolean] :with_members (true) If it opens notifications including group members
610
+ # @option options [Boolean] :skip_validation (true) If it skips validation of the notification record
562
611
  # @return [Integer] Number of opened notification records
563
612
  def open!(options = {})
564
613
  opened? and return 0
@@ -566,7 +615,7 @@ module ActivityNotification
566
615
  with_members = options.has_key?(:with_members) ? options[:with_members] : true
567
616
  unopened_member_count = with_members ? group_members.unopened_only.count : 0
568
617
  group_members.update_all(opened_at: opened_at) if with_members
569
- update(opened_at: opened_at)
618
+ options[:skip_validation] ? update_attribute(:opened_at, opened_at) : update(opened_at: opened_at)
570
619
  unopened_member_count + 1
571
620
  end
572
621
 
@@ -92,6 +92,18 @@ module ActivityNotification
92
92
  extend Swagger::NotificationsParameters::TargetParameters
93
93
  extend Swagger::NotificationsParameters::FilterByParameters
94
94
 
95
+ parameter do
96
+ key :name, :ids
97
+ key :in, :query
98
+ key :description, "Array of specific notification IDs to open"
99
+ key :required, false
100
+ key :type, :array
101
+ items do
102
+ key :type, :string
103
+ end
104
+ key :example, ["1", "2", "3"]
105
+ end
106
+
95
107
  response 200 do
96
108
  key :description, "Opened notifications"
97
109
  content 'application/json' do
@@ -117,6 +129,53 @@ module ActivityNotification
117
129
  end
118
130
  end
119
131
 
132
+ swagger_path '/{target_type}/{target_id}/notifications/destroy_all' do
133
+ operation :post do
134
+ key :summary, 'Destroy all notifications'
135
+ key :description, 'Destroys all notifications of the target matching filter criteria.'
136
+ key :operationId, 'destroyAllNotifications'
137
+ key :tags, ['notifications']
138
+
139
+ extend Swagger::NotificationsParameters::TargetParameters
140
+ extend Swagger::NotificationsParameters::FilterByParameters
141
+
142
+ parameter do
143
+ key :name, :ids
144
+ key :in, :query
145
+ key :description, "Array of specific notification IDs to destroy"
146
+ key :required, false
147
+ key :type, :array
148
+ items do
149
+ key :type, :string
150
+ end
151
+ key :example, ["1", "2", "3"]
152
+ end
153
+
154
+ response 200 do
155
+ key :description, "Destroyed notifications"
156
+ content 'application/json' do
157
+ schema do
158
+ key :type, :object
159
+ property :count do
160
+ key :type, :integer
161
+ key :description, "Number of destroyed notification records"
162
+ key :example, 3
163
+ end
164
+ property :notifications do
165
+ key :type, :array
166
+ items do
167
+ key :'$ref', :Notification
168
+ end
169
+ key :description, "Destroyed notifications"
170
+ end
171
+ end
172
+ end
173
+ end
174
+ extend Swagger::ErrorResponses::InvalidParameterError
175
+ extend Swagger::ErrorResponses::ResourceNotFoundError
176
+ end
177
+ end
178
+
120
179
  swagger_path '/{target_type}/{target_id}/notifications/{id}' do
121
180
  operation :get do
122
181
  key :summary, 'Get notification'
@@ -145,6 +145,20 @@ module ActivityNotification
145
145
  send("open_all_#{routing_scope(options)}#{target.to_resource_name}_notifications_path", target, options)
146
146
  end
147
147
 
148
+ # Returns destroy_all_notifications_path for the target
149
+ #
150
+ # @param [Object] target Target instance
151
+ # @param [Hash] params Request parameters
152
+ # @return [String] destroy_all_notifications_path for the target
153
+ # @todo Needs any other better implementation
154
+ # @todo Must handle devise namespace
155
+ def destroy_all_notifications_path_for(target, params = {})
156
+ options = params.dup
157
+ options.delete(:devise_default_routes) ?
158
+ send("destroy_all_#{routing_scope(options)}notifications_path", options) :
159
+ send("destroy_all_#{routing_scope(options)}#{target.to_resource_name}_notifications_path", target, options)
160
+ end
161
+
148
162
  # Returns notifications_url for the target
149
163
  #
150
164
  # @param [Object] target Target instance
@@ -215,6 +229,20 @@ module ActivityNotification
215
229
  send("open_all_#{routing_scope(options)}#{target.to_resource_name}_notifications_url", target, options)
216
230
  end
217
231
 
232
+ # Returns destroy_all_notifications_url for the target
233
+ #
234
+ # @param [Object] target Target instance
235
+ # @param [Hash] params Request parameters
236
+ # @return [String] destroy_all_notifications_url for the target
237
+ # @todo Needs any other better implementation
238
+ # @todo Must handle devise namespace
239
+ def destroy_all_notifications_url_for(target, params = {})
240
+ options = params.dup
241
+ options.delete(:devise_default_routes) ?
242
+ send("destroy_all_#{routing_scope(options)}notifications_url", options) :
243
+ send("destroy_all_#{routing_scope(options)}#{target.to_resource_name}_notifications_url", target, options)
244
+ end
245
+
218
246
  # Returns subscriptions_path for the target
219
247
  #
220
248
  # @param [Object] target Target instance
@@ -5,6 +5,7 @@ module ActivityNotification
5
5
  # Use to resolve parameters from email configuration and send notification email.
6
6
  module Helpers
7
7
  extend ActiveSupport::Concern
8
+ include ActivityNotification::NotificationResilience
8
9
 
9
10
  protected
10
11
 
@@ -13,10 +14,13 @@ module ActivityNotification
13
14
  # @param [Notification] notification Notification instance to send email
14
15
  # @param [Hash] options Options for notification email
15
16
  # @option options [String, Symbol] :fallback (:default) Fallback template to use when MissingTemplate is raised
17
+ # @return [Mail::Message, nil] Email message or nil if notification was not found
16
18
  def notification_mail(notification, options = {})
17
- initialize_from_notification(notification)
18
- headers = headers_for(notification.key, options)
19
- send_mail(headers, options[:fallback])
19
+ with_notification_resilience(notification&.id, { target: 'unknown' }) do
20
+ initialize_from_notification(notification)
21
+ headers = headers_for(notification.key, options)
22
+ send_mail(headers, options[:fallback])
23
+ end
20
24
  end
21
25
 
22
26
  # Send batch notification email with configured options.
@@ -26,11 +30,14 @@ module ActivityNotification
26
30
  # @param [String] batch_key Key of the batch notification email
27
31
  # @param [Hash] options Options for notification email
28
32
  # @option options [String, Symbol] :fallback (:batch_default) Fallback template to use when MissingTemplate is raised
33
+ # @return [Mail::Message, nil] Email message or nil if notifications were not found
29
34
  def batch_notification_mail(target, notifications, batch_key, options = {})
30
- initialize_from_notifications(target, notifications)
31
- headers = headers_for(batch_key, options)
32
- @notification = nil
33
- send_mail(headers, options[:fallback])
35
+ with_notification_resilience(notifications&.first&.id, { target: target&.class&.name, batch: true }) do
36
+ initialize_from_notifications(target, notifications)
37
+ headers = headers_for(batch_key, options)
38
+ @notification = nil
39
+ send_mail(headers, options[:fallback])
40
+ end
34
41
  end
35
42
 
36
43
  # Initialize instance variables from notification.
@@ -85,7 +85,7 @@ module ActivityNotification
85
85
  },
86
86
  subscribed_at: {
87
87
  type: "string",
88
- format: "date-time"
88
+ nullable: true
89
89
  }
90
90
  }
91
91
  }
@@ -419,6 +419,22 @@ module ActivityNotification
419
419
  Notification.open_all_of(self, options)
420
420
  end
421
421
 
422
+ # Destroys all notifications of the target matching the filter criteria.
423
+ #
424
+ # @param [Hash] options Options for filtering notifications to destroy
425
+ # @option options [String] :filtered_by_type (nil) Notifiable type for filter
426
+ # @option options [Object] :filtered_by_group (nil) Group instance for filter
427
+ # @option options [String] :filtered_by_group_type (nil) Group type for filter, valid with :filtered_by_group_id
428
+ # @option options [String] :filtered_by_group_id (nil) Group instance id for filter, valid with :filtered_by_group_type
429
+ # @option options [String] :filtered_by_key (nil) Key of the notification for filter
430
+ # @option options [String] :later_than (nil) ISO 8601 format time to filter notifications later than specified time
431
+ # @option options [String] :earlier_than (nil) ISO 8601 format time to filter notifications earlier than specified time
432
+ # @option options [Array] :ids (nil) Array of specific notification IDs to destroy
433
+ # @return [Array<Notification>] Destroyed notification records
434
+ def destroy_all_notifications(options = {})
435
+ Notification.destroy_all_of(self, options)
436
+ end
437
+
422
438
 
423
439
  # Gets automatically arranged notification index of the target with included attributes like target, notifiable, group and notifier.
424
440
  # This method is the typical way to get notifications index from controller of view.
@@ -18,11 +18,11 @@ module ActivityNotification
18
18
  end
19
19
 
20
20
  if defined?(ActiveRecord::Base)
21
+ # :nocov:
21
22
  ActiveRecord::Base.class_eval { include ActivityNotification::Models }
22
23
 
23
24
  # https://github.com/simukappu/activity_notification/issues/166
24
25
  # https://discuss.rubyonrails.org/t/cve-2022-32224-possible-rce-escalation-bug-with-serialized-columns-in-active-record/81017
25
- # :nocov:
26
26
  if (Gem::Version.new("5.2.8.1") <= Rails.gem_version && Rails.gem_version < Gem::Version.new("6.0")) ||
27
27
  (Gem::Version.new("6.0.5.1") <= Rails.gem_version && Rails.gem_version < Gem::Version.new("6.1")) ||
28
28
  (Gem::Version.new("6.1.6.1") <= Rails.gem_version && Rails.gem_version < Gem::Version.new("7.0"))
@@ -0,0 +1,115 @@
1
+ module ActivityNotification
2
+ # Provides resilient notification handling across different ORMs
3
+ # Handles missing notification scenarios gracefully without raising exceptions
4
+ module NotificationResilience
5
+ extend ActiveSupport::Concern
6
+
7
+ # Exception classes for different ORMs
8
+ ORM_EXCEPTIONS = {
9
+ active_record: 'ActiveRecord::RecordNotFound',
10
+ mongoid: 'Mongoid::Errors::DocumentNotFound',
11
+ dynamoid: 'Dynamoid::Errors::RecordNotFound'
12
+ }.freeze
13
+
14
+ class_methods do
15
+ # Returns the current ORM being used
16
+ # @return [Symbol] The ORM symbol (:active_record, :mongoid, :dynamoid)
17
+ def current_orm
18
+ ActivityNotification.config.orm
19
+ end
20
+
21
+ # Returns the exception class for the current ORM
22
+ # @return [Class] The exception class for missing records in current ORM
23
+ def record_not_found_exception_class
24
+ exception_name = ORM_EXCEPTIONS[current_orm]
25
+ return nil unless exception_name
26
+
27
+ begin
28
+ exception_name.constantize
29
+ rescue NameError
30
+ nil
31
+ end
32
+ end
33
+
34
+ # Checks if an exception is a "record not found" exception for any supported ORM
35
+ # @param [Exception] exception The exception to check
36
+ # @return [Boolean] True if the exception indicates a missing record
37
+ def record_not_found_exception?(exception)
38
+ ORM_EXCEPTIONS.values.any? do |exception_name|
39
+ begin
40
+ exception.is_a?(exception_name.constantize)
41
+ rescue NameError
42
+ false
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ # Module-level methods that delegate to class methods
49
+ def self.current_orm
50
+ ActivityNotification.config.orm
51
+ end
52
+
53
+ def self.record_not_found_exception_class
54
+ exception_name = ORM_EXCEPTIONS[current_orm]
55
+ return nil unless exception_name
56
+
57
+ begin
58
+ exception_name.constantize
59
+ rescue NameError
60
+ nil
61
+ end
62
+ end
63
+
64
+ def self.record_not_found_exception?(exception)
65
+ ORM_EXCEPTIONS.values.any? do |exception_name|
66
+ begin
67
+ exception.is_a?(exception_name.constantize)
68
+ rescue NameError
69
+ false
70
+ end
71
+ end
72
+ end
73
+
74
+ # Executes a block with resilient notification handling
75
+ # Catches ORM-specific "record not found" exceptions and logs them appropriately
76
+ # @param [String, Integer] notification_id The ID of the notification being processed
77
+ # @param [Hash] context Additional context for logging
78
+ # @yield Block to execute with resilient handling
79
+ # @return [Object, nil] Result of the block, or nil if notification was not found
80
+ def with_notification_resilience(notification_id = nil, context = {})
81
+ yield
82
+ rescue => exception
83
+ if self.class.record_not_found_exception?(exception)
84
+ log_missing_notification(notification_id, exception, context)
85
+ nil
86
+ else
87
+ raise exception
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # Logs a warning when a notification is not found
94
+ # @param [String, Integer] notification_id The ID of the missing notification
95
+ # @param [Exception] exception The exception that was caught
96
+ # @param [Hash] context Additional context for logging
97
+ def log_missing_notification(notification_id, exception, context = {})
98
+ orm_name = self.class.current_orm
99
+ exception_class = exception.class.name
100
+
101
+ message = "ActivityNotification: Notification"
102
+ message += " with id #{notification_id}" if notification_id
103
+ message += " not found for email delivery"
104
+ message += " (#{orm_name}/#{exception_class})"
105
+ message += ", likely destroyed before job execution"
106
+
107
+ if context.any?
108
+ context_info = context.map { |k, v| "#{k}: #{v}" }.join(', ')
109
+ message += " [#{context_info}]"
110
+ end
111
+
112
+ Rails.logger.warn(message)
113
+ end
114
+ end
115
+ end