activity_notification 2.3.2 → 2.4.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.
- checksums.yaml +4 -4
- data/.github/workflows/build.yml +9 -36
- data/CHANGELOG.md +26 -1
- data/Gemfile +1 -1
- data/README.md +9 -1
- data/activity_notification.gemspec +5 -5
- data/ai-curated-specs/issues/172/design.md +220 -0
- data/ai-curated-specs/issues/172/tasks.md +326 -0
- data/ai-curated-specs/issues/188/design.md +227 -0
- data/ai-curated-specs/issues/188/requirements.md +78 -0
- data/ai-curated-specs/issues/188/tasks.md +203 -0
- data/ai-curated-specs/issues/188/upstream-contributions.md +592 -0
- data/ai-curated-specs/issues/50/design.md +235 -0
- data/ai-curated-specs/issues/50/requirements.md +49 -0
- data/ai-curated-specs/issues/50/tasks.md +232 -0
- data/app/controllers/activity_notification/notifications_api_controller.rb +22 -0
- data/app/controllers/activity_notification/notifications_controller.rb +27 -1
- data/app/mailers/activity_notification/mailer.rb +2 -2
- data/app/views/activity_notification/notifications/default/_index.html.erb +6 -1
- data/app/views/activity_notification/notifications/default/destroy_all.js.erb +6 -0
- data/docs/Setup.md +43 -6
- data/gemfiles/Gemfile.rails-7.0 +2 -0
- data/gemfiles/Gemfile.rails-7.2 +0 -2
- data/gemfiles/Gemfile.rails-8.0 +24 -0
- data/lib/activity_notification/apis/notification_api.rb +51 -2
- data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
- data/lib/activity_notification/helpers/view_helpers.rb +28 -0
- data/lib/activity_notification/mailers/helpers.rb +14 -7
- data/lib/activity_notification/models/concerns/target.rb +16 -0
- data/lib/activity_notification/models.rb +1 -1
- data/lib/activity_notification/notification_resilience.rb +115 -0
- data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
- data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
- data/lib/activity_notification/orm/dynamoid.rb +42 -6
- data/lib/activity_notification/rails/routes.rb +3 -2
- data/lib/activity_notification/version.rb +1 -1
- data/lib/activity_notification.rb +1 -0
- data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
- data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
- data/spec/concerns/apis/notification_api_spec.rb +161 -5
- data/spec/concerns/models/target_spec.rb +7 -0
- data/spec/controllers/controller_spec_utility.rb +1 -1
- data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
- data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
- data/spec/helpers/view_helpers_spec.rb +14 -0
- data/spec/jobs/notification_resilience_job_spec.rb +167 -0
- data/spec/mailers/notification_resilience_spec.rb +263 -0
- data/spec/models/notification_spec.rb +1 -1
- data/spec/models/subscription_spec.rb +1 -1
- data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
- data/spec/rails_app/config/application.rb +1 -0
- data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
- metadata +67 -53
data/docs/Setup.md
CHANGED
@@ -80,12 +80,6 @@ You need to configure Mongoid in your Rails application for your MongoDB environ
|
|
80
80
|
|
81
81
|
#### Using Dynamoid ORM
|
82
82
|
|
83
|
-
Currently, *activity_notification* only works with Dynamoid 3.1.0.
|
84
|
-
|
85
|
-
```ruby
|
86
|
-
gem 'dynamoid', '3.1.0'
|
87
|
-
```
|
88
|
-
|
89
83
|
When you use *activity_notification* with [Dynamoid](https://github.com/Dynamoid/dynamoid) ORM, set **AN_ORM** environment variable to **dynamoid**:
|
90
84
|
|
91
85
|
```console
|
@@ -762,6 +756,49 @@ notification:
|
|
762
756
|
|
763
757
|
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
758
|
|
759
|
+
### Managing notifications
|
760
|
+
|
761
|
+
*activity_notification* provides several methods to manage notifications programmatically. The most common operation is opening notifications to mark them as read.
|
762
|
+
|
763
|
+
#### Opening notifications
|
764
|
+
|
765
|
+
You can mark individual notifications as opened (read) using the **open!** method:
|
766
|
+
|
767
|
+
```ruby
|
768
|
+
# Open a single notification
|
769
|
+
notification = current_user.notifications.first
|
770
|
+
notification.open!
|
771
|
+
|
772
|
+
# Open notification with specific timestamp
|
773
|
+
notification.open!(opened_at: 1.hour.ago)
|
774
|
+
|
775
|
+
# Open notification with opening group members
|
776
|
+
notification.open!(with_members: true)
|
777
|
+
|
778
|
+
# Open notification skipping validations when the associated notifiable record may have been deleted
|
779
|
+
notification.open!(skip_validation: true)
|
780
|
+
```
|
781
|
+
|
782
|
+
The **open!** method accepts the following options:
|
783
|
+
|
784
|
+
* **:opened_at** (Time) - Time to set as the opened timestamp (defaults to `Time.current`)
|
785
|
+
* **:with_members** (Boolean) - Whether to open group member notifications as well (defaults to `false`)
|
786
|
+
* **: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.
|
787
|
+
|
788
|
+
You can also open all notifications for a target:
|
789
|
+
|
790
|
+
```ruby
|
791
|
+
# Open all unopened notifications for a user
|
792
|
+
ActivityNotification::Notification.open_all_of(current_user)
|
793
|
+
|
794
|
+
# Open notifications with filters
|
795
|
+
ActivityNotification::Notification.open_all_of(
|
796
|
+
current_user,
|
797
|
+
filtered_by_type: 'Comment',
|
798
|
+
opened_at: 1.hour.ago
|
799
|
+
)
|
800
|
+
```
|
801
|
+
|
765
802
|
### Customizing controllers (optional)
|
766
803
|
|
767
804
|
If the customization at the views level is not enough, you can customize each controller by following these steps:
|
data/gemfiles/Gemfile.rails-7.0
CHANGED
data/gemfiles/Gemfile.rails-7.2
CHANGED
@@ -4,8 +4,6 @@ gemspec path: '../'
|
|
4
4
|
|
5
5
|
gem 'rails', '~> 7.2.0'
|
6
6
|
gem 'sprockets-rails'
|
7
|
-
# https://github.com/lynndylanhurley/devise_token_auth/pull/1632
|
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'
|
@@ -0,0 +1,24 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
gemspec path: '../'
|
4
|
+
|
5
|
+
gem 'rails', '~> 8.0.0'
|
6
|
+
gem 'sprockets-rails'
|
7
|
+
|
8
|
+
group :development do
|
9
|
+
gem 'bullet'
|
10
|
+
gem 'rack-cors'
|
11
|
+
gem 'sqlite3'
|
12
|
+
end
|
13
|
+
|
14
|
+
group :test do
|
15
|
+
gem 'rails-controller-testing'
|
16
|
+
gem 'ammeter'
|
17
|
+
gem 'timecop'
|
18
|
+
gem 'committee'
|
19
|
+
gem 'committee-rails', '< 0.6'
|
20
|
+
# gem 'coveralls', require: false
|
21
|
+
gem 'coveralls_reborn', require: false
|
22
|
+
end
|
23
|
+
|
24
|
+
gem 'dotenv-rails', groups: [:development, :test]
|
@@ -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
|
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
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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.
|
@@ -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
|
@@ -83,92 +83,6 @@ module Dynamoid # :nodoc: all
|
|
83
83
|
end
|
84
84
|
end
|
85
85
|
|
86
|
-
# Entend Dynamoid to support query and scan with 'null' and 'not_null' conditions
|
87
|
-
# @private
|
88
|
-
module Dynamoid # :nodoc: all
|
89
|
-
# @private
|
90
|
-
module Criteria
|
91
|
-
# https://github.com/Dynamoid/dynamoid/blob/master/lib/dynamoid/criteria/chain.rb
|
92
|
-
# @private
|
93
|
-
module NullOperatorExtension
|
94
|
-
# @private
|
95
|
-
def field_hash(key)
|
96
|
-
name, operation = key.to_s.split('.')
|
97
|
-
val = type_cast_condition_parameter(name, query[key])
|
98
|
-
|
99
|
-
hash = case operation
|
100
|
-
when 'null'
|
101
|
-
{ null: val }
|
102
|
-
when 'not_null'
|
103
|
-
{ not_null: val }
|
104
|
-
else
|
105
|
-
return super(key)
|
106
|
-
end
|
107
|
-
|
108
|
-
{ name.to_sym => hash }
|
109
|
-
end
|
110
|
-
end
|
111
|
-
|
112
|
-
# https://github.com/Dynamoid/dynamoid/blob/master/lib/dynamoid/criteria/chain.rb
|
113
|
-
# @private
|
114
|
-
class Chain
|
115
|
-
prepend NullOperatorExtension
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
|
-
# @private
|
120
|
-
module AdapterPlugin
|
121
|
-
# @private
|
122
|
-
class AwsSdkV3
|
123
|
-
|
124
|
-
NULL_OPERATOR_FIELD_MAP = {
|
125
|
-
null: 'NULL',
|
126
|
-
not_null: 'NOT_NULL'
|
127
|
-
}.freeze
|
128
|
-
|
129
|
-
# https://github.com/Dynamoid/dynamoid/blob/master/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb
|
130
|
-
# @private
|
131
|
-
class Query < ::Dynamoid::AdapterPlugin::Query
|
132
|
-
# @private
|
133
|
-
def query_filter
|
134
|
-
conditions.except(*AwsSdkV3::RANGE_MAP.keys).reduce({}) do |result, (attr, cond)|
|
135
|
-
if AwsSdkV3::NULL_OPERATOR_FIELD_MAP.has_key?(cond.keys[0])
|
136
|
-
condition = { comparison_operator: AwsSdkV3::NULL_OPERATOR_FIELD_MAP[cond.keys[0]] }
|
137
|
-
else
|
138
|
-
condition = {
|
139
|
-
comparison_operator: AwsSdkV3::FIELD_MAP[cond.keys[0]],
|
140
|
-
attribute_value_list: AwsSdkV3.attribute_value_list(AwsSdkV3::FIELD_MAP[cond.keys[0]], cond.values[0].freeze)
|
141
|
-
}
|
142
|
-
end
|
143
|
-
result[attr] = condition
|
144
|
-
result
|
145
|
-
end
|
146
|
-
end
|
147
|
-
end
|
148
|
-
|
149
|
-
# https://github.com/Dynamoid/dynamoid/blob/master/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb
|
150
|
-
# @private
|
151
|
-
class Scan < ::Dynamoid::AdapterPlugin::Scan
|
152
|
-
# @private
|
153
|
-
def scan_filter
|
154
|
-
conditions.reduce({}) do |result, (attr, cond)|
|
155
|
-
if AwsSdkV3::NULL_OPERATOR_FIELD_MAP.has_key?(cond.keys[0])
|
156
|
-
condition = { comparison_operator: AwsSdkV3::NULL_OPERATOR_FIELD_MAP[cond.keys[0]] }
|
157
|
-
else
|
158
|
-
condition = {
|
159
|
-
comparison_operator: AwsSdkV3::FIELD_MAP[cond.keys[0]],
|
160
|
-
attribute_value_list: AwsSdkV3.attribute_value_list(AwsSdkV3::FIELD_MAP[cond.keys[0]], cond.values[0].freeze)
|
161
|
-
}
|
162
|
-
end
|
163
|
-
result[attr] = condition
|
164
|
-
result
|
165
|
-
end
|
166
|
-
end
|
167
|
-
end
|
168
|
-
end
|
169
|
-
end
|
170
|
-
end
|
171
|
-
|
172
86
|
# Entend Dynamoid to support uniqueness validator
|
173
87
|
# @private
|
174
88
|
module Dynamoid # :nodoc: all
|
@@ -183,7 +97,10 @@ module Dynamoid # :nodoc: all
|
|
183
97
|
# @param [Object] value The value of the object.
|
184
98
|
def validate_each(document, attribute, value)
|
185
99
|
return unless validation_required?(document, attribute)
|
186
|
-
|
100
|
+
if not_unique?(document, attribute, value)
|
101
|
+
error_options = options.except(:scope).merge(value: value)
|
102
|
+
document.errors.add(attribute, :taken, **error_options)
|
103
|
+
end
|
187
104
|
end
|
188
105
|
|
189
106
|
private
|
@@ -42,13 +42,30 @@ module ActivityNotification
|
|
42
42
|
# Group owner instance has nil as :group_owner association.
|
43
43
|
# @scope instance
|
44
44
|
# @return [Notification] Group owner notification instance of this notification
|
45
|
-
|
45
|
+
# Note: Dynamoid doesn't support belongs_to, so we implement it manually
|
46
46
|
|
47
47
|
# Customized method that belongs to group owner notification instance of this notification.
|
48
48
|
# @raise [Errors::RecordNotFound] Record not found error
|
49
49
|
# @return [Notification] Group owner notification instance of this notification
|
50
50
|
def group_owner
|
51
|
-
group_owner_id.nil? ? nil : Notification.find(group_owner_id)
|
51
|
+
group_owner_id.nil? ? nil : Notification.find(group_owner_id, raise_error: false)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Setter method for group_owner association
|
55
|
+
# @param [Notification, nil] notification Group owner notification instance
|
56
|
+
def group_owner=(notification)
|
57
|
+
self.group_owner_id = notification.nil? ? nil : notification.id
|
58
|
+
end
|
59
|
+
|
60
|
+
# Override reload method to refresh the record from database
|
61
|
+
def reload
|
62
|
+
fresh_record = self.class.find(id)
|
63
|
+
if fresh_record
|
64
|
+
# Update specific attributes we care about
|
65
|
+
self.group_owner_id = fresh_record.group_owner_id
|
66
|
+
self.opened_at = fresh_record.opened_at
|
67
|
+
end
|
68
|
+
self
|
52
69
|
end
|
53
70
|
|
54
71
|
# Has many group member notification instances of this notification.
|