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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/build.yml +9 -36
  3. data/CHANGELOG.md +26 -1
  4. data/Gemfile +1 -1
  5. data/README.md +9 -1
  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/Setup.md +43 -6
  22. data/gemfiles/Gemfile.rails-7.0 +2 -0
  23. data/gemfiles/Gemfile.rails-7.2 +0 -2
  24. data/gemfiles/Gemfile.rails-8.0 +24 -0
  25. data/lib/activity_notification/apis/notification_api.rb +51 -2
  26. data/lib/activity_notification/controllers/concerns/swagger/notifications_api.rb +59 -0
  27. data/lib/activity_notification/helpers/view_helpers.rb +28 -0
  28. data/lib/activity_notification/mailers/helpers.rb +14 -7
  29. data/lib/activity_notification/models/concerns/target.rb +16 -0
  30. data/lib/activity_notification/models.rb +1 -1
  31. data/lib/activity_notification/notification_resilience.rb +115 -0
  32. data/lib/activity_notification/orm/dynamoid/extension.rb +4 -87
  33. data/lib/activity_notification/orm/dynamoid/notification.rb +19 -2
  34. data/lib/activity_notification/orm/dynamoid.rb +42 -6
  35. data/lib/activity_notification/rails/routes.rb +3 -2
  36. data/lib/activity_notification/version.rb +1 -1
  37. data/lib/activity_notification.rb +1 -0
  38. data/lib/generators/templates/controllers/notifications_api_controller.rb +5 -0
  39. data/lib/generators/templates/controllers/notifications_api_with_devise_controller.rb +5 -0
  40. data/lib/generators/templates/controllers/notifications_controller.rb +5 -0
  41. data/lib/generators/templates/controllers/notifications_with_devise_controller.rb +5 -0
  42. data/spec/concerns/apis/notification_api_spec.rb +161 -5
  43. data/spec/concerns/models/target_spec.rb +7 -0
  44. data/spec/controllers/controller_spec_utility.rb +1 -1
  45. data/spec/controllers/notifications_api_controller_shared_examples.rb +113 -0
  46. data/spec/controllers/notifications_controller_shared_examples.rb +150 -0
  47. data/spec/helpers/view_helpers_spec.rb +14 -0
  48. data/spec/jobs/notification_resilience_job_spec.rb +167 -0
  49. data/spec/mailers/notification_resilience_spec.rb +263 -0
  50. data/spec/models/notification_spec.rb +1 -1
  51. data/spec/models/subscription_spec.rb +1 -1
  52. data/spec/rails_app/app/helpers/devise_helper.rb +2 -0
  53. data/spec/rails_app/config/application.rb +1 -0
  54. data/spec/rails_app/config/initializers/zeitwerk.rb +10 -0
  55. 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:
@@ -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', '~> 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 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.
@@ -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
- document.errors.add(attribute, :taken, options.except(:scope).merge(value: value)) if not_unique?(document, attribute, value)
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
- belongs_to :group_owner, { class_name: "ActivityNotification::Notification", foreign_key: :group_owner_id, optional: true }
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.